├── .gitignore ├── LHFLM.png ├── README.md ├── board-images ├── board1.png ├── board2.png ├── board3.png ├── board4.png └── board5.png ├── bw_image.go ├── bw_image_test.go ├── cmd ├── recognize │ └── recognize.go ├── train │ └── train.go └── wf │ └── wf.go ├── debug_output └── .keep ├── go.mod ├── image.go ├── image_test.go ├── letterpress.go ├── letterpress_test.go ├── network.go ├── network_test.go ├── solver.go ├── solver_test.go ├── tile.go └── words-en.txt /.gitignore: -------------------------------------------------------------------------------- 1 | debug_output/** 2 | !.keep 3 | ocr.save 4 | -------------------------------------------------------------------------------- /LHFLM.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/armhold/gocarina/963260cb665c7d57f72bec2354297e69bf8abf21/LHFLM.png -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GOCARINA - simple Optical Character Recognition in Go 2 | 3 | Gocarina uses a neural network to do simple Optical Character Recognition (OCR). 4 | It's trained on [Letterpress®](http://www.atebits.com/letterpress) game boards. 5 | 6 | ![LHFLM](https://github.com/armhold/gocarina/blob/master/LHFLM.png "LHFLM") 7 | 8 | 9 | ## Usage 10 | 11 | First, build the software: 12 | 13 | ``` 14 | $ git clone https://github.com/armhold/gocarina.git 15 | $ cd gocarina 16 | $ go build ./cmd/train && go build ./cmd/recognize 17 | ``` 18 | 19 | Next, we need to create and train a network. Be sure to first connect to the source directory 20 | (`train` expects the game boards to appear in `board-images/`): 21 | 22 | ``` 23 | $ ./train 24 | creating new network... 25 | Network: NumInputs: 144, NumOutputs: 8, HiddenCount: 152 26 | success took 58 iterations 27 | success rate: 26/26 => %100.00 28 | ``` 29 | 30 | You now have a trained neural network in `ocr.save`. If you got a failure message, simply try running it again; 31 | sometimes it takes a few attempts to get a successful training (weights are assigned by random number generator). 32 | 33 | Once you have a successfully trained network, you can ask it to decipher game boards like this: 34 | 35 | `$ ./recognize board-images/board3.png` 36 | ``` 37 | L H F L M 38 | R V P U K 39 | V O E E X 40 | I N R I T 41 | V N S I Q 42 | ``` 43 | 44 | You can also ask it to give you a list of words that can be formed with the board: 45 | 46 | `$ ./recognize -w board-images/board3.png` 47 | ``` 48 | L H F L M 49 | R V P U K 50 | V O E E X 51 | I N R I T 52 | V N S I Q 53 | 54 | 55 | overmultiplies 56 | relinquishment 57 | feuilletonism 58 | fluorimetries 59 | interinvolves 60 | pluviometries 61 | reptiliferous 62 | [etc...] 63 | ``` 64 | 65 | 66 | ## How it works 67 | 68 | We start with three "known" game boards. We split them up into individual tiles, one per letter. 69 | This covers the entire alphabet, and gives us our training set. We feed the training tiles into the network 70 | one at a time, and calculate the error value for the expected vs. the actual result. We do this repeatedly, 71 | until the network is trained (typically requires < 100 iterations). 72 | 73 | 74 | ## Representation & Encoding for the Neural Network 75 | 76 | The tiles are quantized to black & white, bounding boxed, and finally scaled down to a small rectangular bitmap. 77 | These bits are then fed directly into the inputs of the network. 78 | 79 | We use a bit string to represent a given letter. 8 bits allows us to represent up to 256 different characters, 80 | which is more than sufficient to cover the 26 characters used in Letterpress (we could certainly get away 81 | with using only 5 bits, but I wanted to hold the door open for potentially doing more than just A-Z). So our 82 | network has 8 outputs, corresponding to the 8 bits of our letters. For convenience, we use the ASCII/Unicode 83 | mapping where 'A' = 65, aka 01000001. 84 | 85 | 86 | ## Can I use this as a production-ready OCR package? 87 | 88 | Doubtful. This is more or less a toy implementation of OCR that operates on a very restricted set of input. 89 | It was created by an AI-hobbyist (not an expert), for fun and for educational purposes. However there's nothing 90 | stopping you from building something more robust, based on what you've learned here. 91 | 92 | A further caveat: this software expects game boards to be 640x1136 pixels, as that is the size generated by 93 | my iPhone5. Your mobile device likely uses a different board size, based on its screen. Gocarina automatically 94 | scales the boards to the expected size, but I haven't tested it exhaustively with every mobile device; you might 95 | have more success in adjusting the geometry values such as `LetterPressExpectedWidth`, than with scaling alone. 96 | 97 | 98 | ## What's with the name? 99 | 100 | This is a Golang port of the [Ruby project](https://github.com/armhold/ocarina) I did a few years back. 101 | Original project: "Ocarina", **OC**a**R**ina, i.e. OCR. Go + Ocarina => Gocarina. 102 | 103 | 104 | ## Credits 105 | 106 | The file `words-en.txt` is in the Public Domain, licensed under CC0 thanks to https://github.com/atebits/Words. 107 | 108 | Letterpress® is a registered mark of Atebits/Solebon. The Gocarina open-source software is in no way 109 | affiliated with, nor is it endorsed by, the trademark holder. 110 | -------------------------------------------------------------------------------- /board-images/board1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/armhold/gocarina/963260cb665c7d57f72bec2354297e69bf8abf21/board-images/board1.png -------------------------------------------------------------------------------- /board-images/board2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/armhold/gocarina/963260cb665c7d57f72bec2354297e69bf8abf21/board-images/board2.png -------------------------------------------------------------------------------- /board-images/board3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/armhold/gocarina/963260cb665c7d57f72bec2354297e69bf8abf21/board-images/board3.png -------------------------------------------------------------------------------- /board-images/board4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/armhold/gocarina/963260cb665c7d57f72bec2354297e69bf8abf21/board-images/board4.png -------------------------------------------------------------------------------- /board-images/board5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/armhold/gocarina/963260cb665c7d57f72bec2354297e69bf8abf21/board-images/board5.png -------------------------------------------------------------------------------- /bw_image.go: -------------------------------------------------------------------------------- 1 | package gocarina 2 | 3 | import ( 4 | "image" 5 | "image/color" 6 | ) 7 | 8 | var ( 9 | bwPalette = color.Palette([]color.Color{color.Black, color.White}) 10 | br, bg, bb, ba = color.Black.RGBA() 11 | wr, wg, wb, wa = color.White.RGBA() 12 | ) 13 | 14 | // Converted uses a Black & White color model to quantize images to black & white. 15 | // credit to Hjulle: http://stackoverflow.com/a/17076395/93995 16 | // 17 | type Converted struct { 18 | Img image.Image 19 | Mod color.Model 20 | } 21 | 22 | func (c *Converted) ColorModel() color.Model { 23 | return c.Mod 24 | } 25 | 26 | func (c *Converted) Bounds() image.Rectangle { 27 | return c.Img.Bounds() 28 | } 29 | 30 | // At forwards the call to the original image, then quantizes to Black or White by 31 | // applying a threshold. 32 | func (c *Converted) At(x, y int) color.Color { 33 | r, g, b, _ := c.Img.At(x, y).RGBA() 34 | 35 | combined := r + g + b 36 | 37 | if combined < 50000 { 38 | return color.Black 39 | } 40 | 41 | return color.White 42 | } 43 | 44 | func (c *Converted) SubImage(r image.Rectangle) image.Image { 45 | sub := c.Img.(interface { 46 | SubImage(r image.Rectangle) image.Image 47 | }).SubImage(r) 48 | 49 | // preserve the B&W color model 50 | return &Converted{sub, bwPalette} 51 | } 52 | 53 | func BlackWhiteImage(img image.Image) image.Image { 54 | return &Converted{img, bwPalette} 55 | } 56 | 57 | func IsBlack(c color.Color) bool { 58 | r, g, b, a := c.RGBA() 59 | 60 | return r == br && g == bg && b == bb && a == ba 61 | } 62 | 63 | func IsWhite(c color.Color) bool { 64 | r, g, b, a := c.RGBA() 65 | 66 | return r == wr && g == wg && b == wb && a == wa 67 | } 68 | -------------------------------------------------------------------------------- /bw_image_test.go: -------------------------------------------------------------------------------- 1 | package gocarina 2 | 3 | import ( 4 | "image" 5 | "os" 6 | "testing" 7 | ) 8 | 9 | func TestBlackWhiteImage(t *testing.T) { 10 | infile, err := os.Open("board-images/board1.png") 11 | if err != nil { 12 | t.Fatal(err) 13 | } 14 | defer infile.Close() 15 | 16 | img, _, err := image.Decode(infile) 17 | if err != nil { 18 | t.Fatal(err) 19 | } 20 | 21 | bwImg := BlackWhiteImage(img) 22 | 23 | for x := 0; x < bwImg.Bounds().Dx(); x++ { 24 | for y := 0; y < bwImg.Bounds().Dy(); y++ { 25 | c := bwImg.At(x, y) 26 | if !(IsBlack(c) || IsWhite(c)) { 27 | t.Fatalf("not black or white: %+v", c) 28 | } 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /cmd/recognize/recognize.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "log" 7 | "os" 8 | 9 | "github.com/armhold/gocarina" 10 | ) 11 | 12 | var ( 13 | boardFile string 14 | networkFile string 15 | showWords bool 16 | ) 17 | 18 | func init() { 19 | var usage = func() { 20 | u := `Usage: recognize [-w] game_board.png 21 | 22 | Supply a game board file as an argument (e.g. board-images/board1.png), 23 | and 'recognize' will print the letters it contains. 24 | 25 | If -w is specified, the set of words that can be formed by the game board letters 26 | is also printed. 27 | 28 | ` 29 | fmt.Fprintf(os.Stderr, u) 30 | } 31 | 32 | flag.Usage = usage 33 | 34 | flag.StringVar(&networkFile, "network", "ocr.save", "the trained network file to use") 35 | flag.BoolVar(&showWords, "w", false, "show list of words that can be made from the given board") 36 | flag.Parse() 37 | 38 | // remaining args after flags are parsed 39 | args := flag.Args() 40 | if len(args) != 1 { 41 | flag.Usage() 42 | os.Exit(1) 43 | } 44 | 45 | boardFile = args[0] 46 | } 47 | 48 | // Performs OCR on the given game board file, optionally printing the words that can be constructed from the board. 49 | func main() { 50 | log.SetFlags(0) 51 | 52 | if networkFile == "" || boardFile == "" { 53 | flag.Usage() 54 | os.Exit(1) 55 | } 56 | 57 | board := gocarina.ReadUnknownBoard(boardFile) 58 | 59 | //log.Printf("loading network...") 60 | network, err := gocarina.RestoreNetwork(networkFile) 61 | if err != nil { 62 | log.Fatal(err) 63 | } 64 | 65 | line := "" 66 | allLetters := "" 67 | for i, tile := range board.Tiles { 68 | c := network.Recognize(tile.Reduced) 69 | line = line + fmt.Sprintf(" %c", c) 70 | allLetters = allLetters + string(c) 71 | 72 | // print them out shaped like a 5x5 letterpress board 73 | if (i+1)%5 == 0 { 74 | log.Printf(line) 75 | line = "" 76 | } 77 | } 78 | 79 | if showWords { 80 | log.Printf("\n\n") 81 | 82 | words := gocarina.WordsFrom(allLetters) 83 | for _, word := range words { 84 | log.Println(word) 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /cmd/train/train.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "log" 6 | 7 | "github.com/armhold/gocarina" 8 | ) 9 | 10 | var ( 11 | fromFile string 12 | toFile string 13 | maxIter int 14 | ) 15 | 16 | func init() { 17 | flag.StringVar(&fromFile, "load", "", "to load network from a saved file") 18 | flag.StringVar(&toFile, "save", "ocr.save", "to save network to a file") 19 | flag.IntVar(&maxIter, "max", 500, "max number of training iterations") 20 | 21 | flag.Parse() 22 | } 23 | 24 | // Trains a network on the known game boards. 25 | func main() { 26 | log.SetFlags(0) 27 | 28 | // do this first, so we have tile boundaries to create the network 29 | m := gocarina.ReadKnownBoards() 30 | 31 | exampleTile := m['A'].Reduced 32 | pixelCount := exampleTile.Bounds().Dx() * exampleTile.Bounds().Dy() 33 | numInputs := pixelCount 34 | 35 | var n *gocarina.Network 36 | var err error 37 | 38 | if fromFile != "" { 39 | log.Printf("loading network...") 40 | n, err = gocarina.RestoreNetwork(fromFile) 41 | if err != nil { 42 | log.Fatal(err) 43 | } 44 | 45 | if n.NumInputs != numInputs { 46 | log.Fatalf("loaded network has %d inputs, tile has %d", n.NumInputs, numInputs) 47 | } 48 | } else { 49 | log.Printf("creating new network...") 50 | n = gocarina.NewNetwork(numInputs) 51 | } 52 | log.Printf("Network: %s", n) 53 | 54 | // save files for debugging 55 | for _, tile := range m { 56 | tile.SaveBoundedAndReduced() 57 | } 58 | 59 | for i := 0; i < maxIter; i++ { 60 | //log.Printf("training iteration: %d\n", i) 61 | 62 | for r, tile := range m { 63 | n.Train(tile.Reduced, r) 64 | } 65 | 66 | if allCorrect(m, n) { 67 | log.Printf("success took %d iterations", i+1) 68 | break 69 | } 70 | } 71 | 72 | if toFile != "" { 73 | n.Save(toFile) 74 | } 75 | 76 | // show details on success/failure 77 | count := 0 78 | correct := 0 79 | for r, tile := range m { 80 | recognized := n.Recognize(tile.Reduced) 81 | count++ 82 | 83 | if recognized == r { 84 | correct++ 85 | } else { 86 | log.Printf("failure: tile recognized as: %c, should be: %c", recognized, r) 87 | } 88 | } 89 | 90 | successPercent := float64(correct) / float64(count) * 100.0 91 | log.Printf("success rate: %d/%d => %%%.2f", correct, count, successPercent) 92 | } 93 | 94 | // returns true if network has 100% success rate on training data 95 | func allCorrect(m map[rune]*gocarina.Tile, n *gocarina.Network) bool { 96 | for r, tile := range m { 97 | recognized := n.Recognize(tile.Reduced) 98 | if recognized != r { 99 | return false 100 | } 101 | } 102 | 103 | return true 104 | } 105 | -------------------------------------------------------------------------------- /cmd/wf/wf.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/armhold/gocarina" 6 | "log" 7 | "os" 8 | ) 9 | 10 | var ( 11 | allLetters string 12 | ) 13 | 14 | func init() { 15 | if len(os.Args) != 2 { 16 | log.Fatal("Usage: wf [letters], e.g.: wf ttpwnredkocnutlsrcowodwua") 17 | } 18 | 19 | allLetters = os.Args[1] 20 | } 21 | 22 | // wf- "words from": prints list of words that can be formed from a given list of characters. 23 | // Useful for deriving words manually, without bothering with game board images. 24 | func main() { 25 | log.SetFlags(0) 26 | fmt.Printf("words from %s\n", allLetters) 27 | 28 | words := gocarina.WordsFrom(allLetters) 29 | for _, word := range words { 30 | fmt.Println(word) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /debug_output/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/armhold/gocarina/963260cb665c7d57f72bec2354297e69bf8abf21/debug_output/.keep -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/armhold/gocarina 2 | -------------------------------------------------------------------------------- /image.go: -------------------------------------------------------------------------------- 1 | package gocarina 2 | 3 | import ( 4 | "image" 5 | "image/color" 6 | "image/draw" 7 | "math" 8 | "math/rand" 9 | ) 10 | 11 | // BoundingBox returns the minimum rectangle containing all non-white pixels in the source image. 12 | func BoundingBox(src image.Image, border int) image.Rectangle { 13 | min := src.Bounds().Min 14 | max := src.Bounds().Max 15 | 16 | leftX := func() int { 17 | for x := min.X; x < max.X; x++ { 18 | for y := min.Y; y < max.Y; y++ { 19 | c := src.At(x, y) 20 | if IsBlack(c) { 21 | return x - border 22 | } 23 | } 24 | } 25 | 26 | // no non-white pixels found 27 | return min.X 28 | } 29 | 30 | rightX := func() int { 31 | for x := max.X - 1; x >= min.X; x-- { 32 | for y := min.Y; y < max.Y; y++ { 33 | c := src.At(x, y) 34 | if IsBlack(c) { 35 | return x + border 36 | } 37 | } 38 | } 39 | 40 | // no non-white pixels found 41 | return max.X 42 | } 43 | 44 | topY := func() int { 45 | for y := min.Y; y < max.Y; y++ { 46 | for x := min.X; x < max.X; x++ { 47 | c := src.At(x, y) 48 | if IsBlack(c) { 49 | return y - border 50 | } 51 | } 52 | } 53 | 54 | // no non-white pixels found 55 | return max.Y 56 | } 57 | 58 | bottomY := func() int { 59 | for y := max.Y - 1; y >= min.Y; y-- { 60 | for x := min.X; x < max.X; x++ { 61 | c := src.At(x, y) 62 | if IsBlack(c) { 63 | return y + border 64 | } 65 | } 66 | } 67 | 68 | // no non-white pixels found 69 | return max.Y 70 | } 71 | 72 | // TODO: decide if +1 is correct or not 73 | return image.Rect(leftX(), topY(), rightX()+1, bottomY()+1) 74 | } 75 | 76 | // Scale scales the src image to the given rectangle using Nearest Neighbor 77 | func Scale(src image.Image, r image.Rectangle) image.Image { 78 | dst := image.NewRGBA(r) 79 | 80 | sb := src.Bounds() 81 | db := dst.Bounds() 82 | 83 | for y := db.Min.Y; y < db.Max.Y; y++ { 84 | percentDownDest := float64(y) / float64(db.Dy()) 85 | 86 | for x := db.Min.X; x < db.Max.X; x++ { 87 | percentAcrossDest := float64(x) / float64(db.Dx()) 88 | 89 | srcX := int(math.Floor(percentAcrossDest * float64(sb.Dx()))) 90 | srcY := int(math.Floor(percentDownDest * float64(sb.Dy()))) 91 | 92 | pix := src.At(sb.Min.X+srcX, sb.Min.Y+srcY) 93 | dst.Set(x, y, pix) 94 | } 95 | } 96 | 97 | return dst 98 | } 99 | 100 | // NoiseImage randomly alters the pixels of the given image. 101 | // Originally this used randomColor(), but that result in some black pixels, which totally defeats the 102 | // bounding box algorithm. A better BBox algorithm would be nice... 103 | func AddNoise(img *image.RGBA) { 104 | for row := img.Bounds().Min.Y; row < img.Bounds().Max.Y; row++ { 105 | for col := img.Bounds().Min.X; col < img.Bounds().Max.X; col++ { 106 | if rand.Float64() > 0.90 { 107 | //img.Set(col, row, randomColor()) 108 | img.Set(col, row, color.White) 109 | } 110 | } 111 | } 112 | } 113 | 114 | // from http://blog.golang.org/go-imagedraw-package ("Converting an Image to RGBA"), 115 | // modified slightly to be a no-op if the src image is already RGBA 116 | // 117 | func ConvertToRGBA(img image.Image) (result *image.RGBA) { 118 | result, ok := img.(*image.RGBA) 119 | if ok { 120 | return result 121 | } 122 | 123 | b := img.Bounds() 124 | result = image.NewRGBA(image.Rect(0, 0, b.Dx(), b.Dy())) 125 | draw.Draw(result, result.Bounds(), img, b.Min, draw.Src) 126 | 127 | return 128 | } 129 | 130 | // randomColor returns a color with completely random values for RGBA. 131 | func randomColor() color.Color { 132 | // start with non-premultiplied RGBA 133 | c := color.NRGBA{R: uint8(rand.Intn(256)), G: uint8(rand.Intn(256)), B: uint8(rand.Intn(256)), A: uint8(rand.Intn(256))} 134 | return color.RGBAModel.Convert(c) 135 | } 136 | 137 | // ImageToString returns a textual approximation of a black & white image for debugging purposes. 138 | func ImageToString(img image.Image) (result string) { 139 | for row := img.Bounds().Min.Y; row < img.Bounds().Max.Y; row++ { 140 | for col := img.Bounds().Min.X; col < img.Bounds().Max.X; col++ { 141 | if IsBlack(img.At(col, row)) { 142 | result += "." 143 | } else { 144 | result += "O" 145 | } 146 | } 147 | 148 | result += "\n" 149 | } 150 | 151 | return 152 | } 153 | -------------------------------------------------------------------------------- /image_test.go: -------------------------------------------------------------------------------- 1 | package gocarina 2 | 3 | import ( 4 | "image" 5 | "image/color" 6 | "testing" 7 | ) 8 | 9 | // just validating my understanding of image.Rectangle API 10 | func TestGeometry(t *testing.T) { 11 | r := image.Rect(0, 0, 16, 16) 12 | 13 | if r.Dx() != 16 { 14 | t.Fatalf("expected width to be 16") 15 | } 16 | 17 | if r.Dy() != 16 { 18 | t.Fatalf("expected height to be 16") 19 | } 20 | 21 | if r.Min.X != 0 { 22 | t.Fatalf("expected starting X coord to be 0") 23 | } 24 | 25 | // the max is not intended to be included in the range 26 | if r.Max.X != 16 { 27 | t.Fatalf("expected ending X coord to be 16") 28 | } 29 | 30 | if r.Min.Y != 0 { 31 | t.Fatalf("expected starting Y coord to be 0") 32 | } 33 | 34 | // the max is not intended to be included in the range 35 | if r.Max.Y != 16 { 36 | t.Fatalf("expected ending Y coord to be 16") 37 | } 38 | } 39 | 40 | func TestBoundingBox(t *testing.T) { 41 | r := image.Rect(0, 0, 16, 16) 42 | img := image.NewRGBA(r) 43 | 44 | // top left 45 | img.Set(3, 3, color.Black) 46 | 47 | // bottom right 48 | img.Set(12, 12, color.Black) 49 | 50 | bbox := BoundingBox(img, 0) 51 | assertWidth(bbox, 10, t) 52 | assertHeight(bbox, 10, t) 53 | 54 | // now test with border 55 | bbox = BoundingBox(img, 1) 56 | assertWidth(bbox, 12, t) 57 | assertHeight(bbox, 12, t) 58 | } 59 | 60 | func assertWidth(rect image.Rectangle, w int, t *testing.T) { 61 | if rect.Bounds().Dx() != w { 62 | t.Fatalf("expected rect.Bounds().Dx() to be %d, was: %d", w, rect.Bounds().Dx()) 63 | } 64 | } 65 | 66 | func assertHeight(rect image.Rectangle, h int, t *testing.T) { 67 | if rect.Bounds().Dy() != h { 68 | t.Fatalf("expected rect.Bounds().Dy() to be %d, was: %d", h, rect.Bounds().Dy()) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /letterpress.go: -------------------------------------------------------------------------------- 1 | package gocarina 2 | 3 | import ( 4 | "image" 5 | _ "image/png" // register PNG format 6 | "log" 7 | "os" 8 | "strings" 9 | ) 10 | 11 | // describes the geometry of the letterpress board source images 12 | const ( 13 | LetterpressTilesAcross = 5 14 | LetterpressTilesDown = 5 15 | LetterpressTilePixels = 128 16 | LetterpressHeightOffset = 496 17 | LetterPressExpectedWidth = LetterpressTilesAcross * LetterpressTilePixels 18 | LetterpressExpectedHeight = 1136 19 | ) 20 | 21 | // Board represents a Letterpress game board 22 | type Board struct { 23 | img image.Image 24 | Tiles []*Tile 25 | } 26 | 27 | // ReadKnownBoard reads the given file into an image, and assigns letters to the board tiles. 28 | // The returned Board can be used for training a network. 29 | func ReadKnownBoard(file string, letters []rune) *Board { 30 | return readBoard(file, letters) 31 | } 32 | 33 | // ReadUnknownBoard reads the given file into an image, and assigns ? characters to the board tiles. 34 | // The tiles from the returned board can then be sent through a (pre-trained) network to be recognized. 35 | func ReadUnknownBoard(file string) *Board { 36 | letters := []rune(strings.Repeat("?", 25)) 37 | return readBoard(file, letters) 38 | } 39 | 40 | func readBoard(file string, letters []rune) *Board { 41 | b := &Board{} 42 | b.img = readImage(file) 43 | images := b.scaleAndCrop() 44 | for i, img := range images { 45 | tile := NewTile(letters[i], img) 46 | b.Tiles = append(b.Tiles, tile) 47 | } 48 | 49 | return b 50 | } 51 | 52 | // ReadKnownBoards reads in the reference board images and assigns the known-correct letter mappings. 53 | // The resulting map of boards can be used to train a network. 54 | func ReadKnownBoards() map[rune]*Tile { 55 | result := make(map[rune]*Tile) 56 | 57 | letters := []rune{ 58 | 'P', 'R', 'B', 'R', 'Z', 59 | 'T', 'A', 'V', 'Z', 'R', 60 | 'B', 'D', 'A', 'K', 'Y', 61 | 'G', 'I', 'G', 'K', 'F', 62 | 'R', 'Y', 'S', 'J', 'V', 63 | } 64 | 65 | b := ReadKnownBoard("board-images/board1.png", letters) 66 | for _, tile := range b.Tiles { 67 | result[tile.Letter] = tile 68 | } 69 | 70 | letters = []rune{ 71 | 'Q', 'D', 'F', 'P', 'M', 72 | 'N', 'E', 'E', 'S', 'I', 73 | 'A', 'W', 'F', 'M', 'L', 74 | 'F', 'R', 'P', 'T', 'T', 75 | 'K', 'C', 'S', 'S', 'Y', 76 | } 77 | b = ReadKnownBoard("board-images/board2.png", letters) 78 | for _, tile := range b.Tiles { 79 | result[tile.Letter] = tile 80 | } 81 | 82 | letters = []rune{ 83 | 'L', 'H', 'F', 'L', 'M', 84 | 'R', 'V', 'P', 'U', 'K', 85 | 'V', 'O', 'E', 'E', 'X', 86 | 'I', 'N', 'R', 'I', 'T', 87 | 'V', 'N', 'S', 'I', 'Q', 88 | } 89 | b = ReadKnownBoard("board-images/board3.png", letters) 90 | for _, tile := range b.Tiles { 91 | result[tile.Letter] = tile 92 | } 93 | 94 | return result 95 | } 96 | 97 | func readImage(file string) image.Image { 98 | infile, err := os.Open(file) 99 | if err != nil { 100 | log.Fatal(err) 101 | } 102 | defer infile.Close() 103 | 104 | img, _, err := image.Decode(infile) 105 | if err != nil { 106 | log.Fatal(err) 107 | } 108 | 109 | return img 110 | } 111 | 112 | // crops a letterpress screen grab into a slice of tile images, one per letter. 113 | func (b *Board) scaleAndCrop() (result []image.Image) { 114 | if b.img.Bounds().Dx() != LetterPressExpectedWidth || b.img.Bounds().Dy() != LetterpressExpectedHeight { 115 | log.Printf("Scaling...\n") 116 | b.img = Scale(b.img, image.Rect(0, 0, LetterPressExpectedWidth, LetterpressExpectedHeight)) 117 | } 118 | 119 | yOffset := LetterpressHeightOffset 120 | border := 1 121 | 122 | for i := 0; i < LetterpressTilesDown; i++ { 123 | xOffset := 0 124 | 125 | for j := 0; j < LetterpressTilesAcross; j++ { 126 | tileRect := image.Rect(xOffset+border, yOffset+border, xOffset+LetterpressTilePixels-border, yOffset+LetterpressTilePixels-border) 127 | 128 | tile := b.img.(interface { 129 | SubImage(r image.Rectangle) image.Image 130 | }).SubImage(tileRect) 131 | 132 | result = append(result, tile) 133 | 134 | xOffset += LetterpressTilePixels 135 | } 136 | 137 | yOffset += LetterpressTilePixels 138 | } 139 | 140 | return 141 | } 142 | -------------------------------------------------------------------------------- /letterpress_test.go: -------------------------------------------------------------------------------- 1 | package gocarina 2 | 3 | import ( 4 | "fmt" 5 | "image" 6 | "image/png" 7 | "os" 8 | "testing" 9 | ) 10 | 11 | // no assertions, but this exercises the entire board -> tile process, and it's also useful to get 12 | // debugging images to written to debug_output/** 13 | func TestReadKnownBoards(t *testing.T) { 14 | m := ReadKnownBoards() 15 | 16 | for letter, tile := range m { 17 | toFile, err := os.Create(fmt.Sprintf("debug_output/tile_%c.png", letter)) 18 | if err != nil { 19 | t.Fatal(err) 20 | } 21 | defer toFile.Close() 22 | 23 | err = png.Encode(toFile, tile.img) 24 | if err != nil { 25 | t.Fatal(err) 26 | } 27 | } 28 | } 29 | 30 | // Again, no assertions here, but handy way to create a board image with noise. This is a way to convince yourself 31 | // that the network is doing more than a bit-per-bit image comparison. By running the "noised" board through 32 | // the recognizer, we can see how it does on an image that has had some of its pixels disturbed. 33 | func TestNoise(t *testing.T) { 34 | infile, err := os.Open("board-images/board1.png") 35 | if err != nil { 36 | t.Fatal(err) 37 | } 38 | defer infile.Close() 39 | 40 | srcImg, _, err := image.Decode(infile) 41 | if err != nil { 42 | t.Fatal(err) 43 | } 44 | 45 | noiseyImg := ConvertToRGBA(srcImg) 46 | AddNoise(noiseyImg) 47 | 48 | toFile, err := os.Create("debug_output/board1-noise.png") 49 | if err != nil { 50 | t.Fatal(err) 51 | } 52 | defer toFile.Close() 53 | 54 | err = png.Encode(toFile, noiseyImg) 55 | if err != nil { 56 | t.Fatal(err) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /network.go: -------------------------------------------------------------------------------- 1 | 2 | // Package gocarina uses a neural network to implement a very simple form of OCR (Optical Character Recognition). 3 | package gocarina 4 | 5 | import ( 6 | "bytes" 7 | "encoding/gob" 8 | "fmt" 9 | "image" 10 | "image/color" 11 | "io/ioutil" 12 | "log" 13 | "math" 14 | "math/rand" 15 | "strconv" 16 | "time" 17 | ) 18 | 19 | const ( 20 | NumOutputs = 8 // number of output bits. This constrains the range of chars that are recognizable. 21 | MinBoundingBoxPercent = 0.25 // threshold width for imposing a bounding box on char width/height 22 | TileTargetWidth = 12 // tiles get scaled down to these dimensions 23 | TileTargetHeight = 12 24 | ) 25 | 26 | func init() { 27 | rand.Seed(time.Now().UTC().UnixNano()) 28 | } 29 | 30 | // Network implements a feed-forward neural network for detecting letters in bitmap images. 31 | type Network struct { 32 | // TODO: much of the array allocations and math could be simplified by using matrices; 33 | // Consider using github.com/gonum/matrix/mat64 34 | 35 | NumInputs int // total of bits in the image 36 | NumOutputs int // number of bits of output; determines the range of chars we can detect 37 | HiddenCount int // number of hidden nodes 38 | InputValues []uint8 // image bits 39 | InputWeights [][]float64 // weights from inputs -> hidden nodes 40 | HiddenOutputs []float64 // after feed-forward, what the hidden nodes output 41 | OutputWeights [][]float64 // weights from hidden nodes -> output nodes 42 | OutputValues []float64 // after feed-forward, what the output nodes output 43 | OutputErrors []float64 // error from the output nodes 44 | HiddenErrors []float64 // error from the hidden nodes 45 | } 46 | 47 | // NewNetwork returns a new instance of a neural network, with the given number of input nodes. 48 | func NewNetwork(numInputs int) *Network { 49 | hiddenCount := numInputs + NumOutputs // somewhat arbitrary; you should experiment with this value 50 | 51 | n := &Network{NumInputs: numInputs, HiddenCount: hiddenCount, NumOutputs: NumOutputs} 52 | 53 | n.InputValues = make([]uint8, n.NumInputs) 54 | n.OutputValues = make([]float64, n.NumOutputs) 55 | n.OutputErrors = make([]float64, n.NumOutputs) 56 | n.HiddenOutputs = make([]float64, n.NumOutputs) 57 | n.HiddenErrors = make([]float64, n.HiddenCount) 58 | 59 | n.assignRandomWeights() 60 | 61 | return n 62 | } 63 | 64 | func (n *Network) String() string { 65 | return fmt.Sprintf("NumInputs: %d, NumOutputs: %d, HiddenCount: %d", n.NumInputs, n.NumOutputs, n.HiddenCount) 66 | } 67 | 68 | // Train trains the network by sending the given image through the network, expecting the output to be equal to r. 69 | func (n *Network) Train(img image.Image, r rune) { 70 | // feed the image data forward through the network to obtain a result 71 | // 72 | n.assignInputs(img) 73 | n.calculateHiddenOutputs() 74 | n.calculateFinalOutputs() 75 | 76 | // propagate the error correction backward through the net 77 | // 78 | n.calculateOutputErrors(r) 79 | n.calculateHiddenErrors() 80 | n.adjustOutputWeights() 81 | n.adjustInputWeights() 82 | } 83 | 84 | // Attempt to recognize the character displayed on the given image. 85 | func (n *Network) Recognize(img image.Image) rune { 86 | n.assignInputs(img) 87 | n.calculateHiddenOutputs() 88 | n.calculateFinalOutputs() 89 | 90 | // quantize output values 91 | bitstring := "" 92 | for _, v := range n.OutputValues { 93 | //log.Printf("v: %f", v) 94 | bitstring += strconv.Itoa(round(v)) 95 | } 96 | 97 | asciiCode, err := strconv.ParseInt(bitstring, 2, 16) 98 | if err != nil { 99 | log.Fatalf("error in ParseInt for %s: ", err) 100 | } 101 | 102 | //log.Printf("returning bitstring: %s", bitstring) 103 | return rune(asciiCode) 104 | } 105 | 106 | func (n *Network) Save(filePath string) error { 107 | buf := new(bytes.Buffer) 108 | encoder := gob.NewEncoder(buf) 109 | 110 | err := encoder.Encode(n) 111 | if err != nil { 112 | return fmt.Errorf("error encoding network: %s", err) 113 | } 114 | 115 | err = ioutil.WriteFile(filePath, buf.Bytes(), 0644) 116 | if err != nil { 117 | return fmt.Errorf("error writing network to file: %s", err) 118 | } 119 | 120 | return nil 121 | } 122 | 123 | func RestoreNetwork(filePath string) (*Network, error) { 124 | b, err := ioutil.ReadFile(filePath) 125 | if err != nil { 126 | return nil, fmt.Errorf("error reading network file: %s", err) 127 | } 128 | 129 | decoder := gob.NewDecoder(bytes.NewBuffer(b)) 130 | 131 | var result Network 132 | err = decoder.Decode(&result) 133 | if err != nil { 134 | return nil, fmt.Errorf("error decoding network: %s", err) 135 | } 136 | 137 | return &result, nil 138 | } 139 | 140 | // can't believe this isn't in the stdlib! 141 | func round(f float64) int { 142 | if math.Abs(f) < 0.5 { 143 | return 0 144 | } 145 | return int(f + math.Copysign(0.5, f)) 146 | } 147 | 148 | // feed the image into the network 149 | func (n *Network) assignInputs(img image.Image) { 150 | numPixels := img.Bounds().Dx() * img.Bounds().Dy() 151 | if numPixels != n.NumInputs { 152 | log.Fatalf("expected %d inputs, got %d", n.NumInputs, numPixels) 153 | } 154 | //log.Printf("numPixels: %d", numPixels) 155 | 156 | i := 0 157 | for row := img.Bounds().Min.Y; row < img.Bounds().Max.Y; row++ { 158 | for col := img.Bounds().Min.X; col < img.Bounds().Max.X; col++ { 159 | pixel := pixelToBit(img.At(col, row)) 160 | n.InputValues[i] = pixel 161 | i++ 162 | } 163 | } 164 | 165 | if i != n.NumInputs { 166 | log.Fatalf("expected i to be: %d, was: %d", n.NumInputs, i) 167 | } 168 | } 169 | 170 | func pixelToBit(c color.Color) uint8 { 171 | if IsBlack(c) { 172 | return 0 173 | } 174 | 175 | return 1 176 | } 177 | 178 | func (n *Network) assignRandomWeights() { 179 | // input -> hidden weights 180 | // 181 | for i := 0; i < n.NumInputs; i++ { 182 | weights := make([]float64, n.HiddenCount) 183 | 184 | for j := 0; j < len(weights); j++ { 185 | 186 | // we want the overall sum of weights to be < 1 187 | weights[j] = rand.Float64() / float64(n.NumInputs*n.HiddenCount) 188 | } 189 | 190 | n.InputWeights = append(n.InputWeights, weights) 191 | } 192 | 193 | // hidden -> output weights 194 | // 195 | for i := 0; i < n.HiddenCount; i++ { 196 | weights := make([]float64, n.NumOutputs) 197 | 198 | for j := 0; j < len(weights); j++ { 199 | 200 | // we want the overall sum of weights to be < 1 201 | weights[j] = rand.Float64() / float64(n.HiddenCount*n.NumOutputs) 202 | } 203 | 204 | n.OutputWeights = append(n.OutputWeights, weights) 205 | } 206 | } 207 | 208 | func (n *Network) calculateOutputErrors(r rune) { 209 | accumError := 0.0 210 | arrayOfInts := n.runeToArrayOfInts(r) 211 | 212 | // NB: binaryString[i] will return bytes, not a rune. range does the right thing 213 | for i, digit := range arrayOfInts { 214 | //log.Printf("digit: %d", digit) 215 | 216 | digitAsFloat := float64(digit) 217 | err := (digitAsFloat - n.OutputValues[i]) * (1.0 - n.OutputValues[i]) * n.OutputValues[i] 218 | n.OutputErrors[i] = err 219 | accumError += err * err 220 | //log.Printf("accumError: %.10f", accumError) 221 | } 222 | } 223 | 224 | func (n *Network) calculateHiddenErrors() { 225 | for i := 0; i < len(n.HiddenOutputs); i++ { 226 | sum := float64(0) 227 | 228 | for j := 0; j < len(n.OutputErrors); j++ { 229 | sum += n.OutputErrors[j] * n.OutputWeights[i][j] 230 | } 231 | 232 | n.HiddenErrors[i] = n.HiddenOutputs[i] * (1 - n.HiddenOutputs[i]) * sum 233 | } 234 | } 235 | 236 | func (n *Network) adjustOutputWeights() { 237 | for i := 0; i < len(n.HiddenOutputs); i++ { 238 | for j := 0; j < n.NumOutputs; j++ { 239 | n.OutputWeights[i][j] += n.OutputErrors[j] * n.HiddenOutputs[i] 240 | } 241 | } 242 | } 243 | 244 | func (n *Network) adjustInputWeights() { 245 | for i := 0; i < n.NumInputs; i++ { 246 | for j := 0; j < n.HiddenCount; j++ { 247 | //fmt.Printf("i: %d, j: %d, len(n.InputWeights): %d, len(n.HiddenErrors): %d, len(n.InputValues): %d\n", i, j, len(n.InputWeights), len(n.HiddenErrors), len(n.InputValues)) 248 | n.InputWeights[i][j] += n.HiddenErrors[j] * float64(n.InputValues[i]) 249 | } 250 | } 251 | } 252 | 253 | func (n *Network) calculateHiddenOutputs() { 254 | for i := 0; i < len(n.HiddenOutputs); i++ { 255 | sum := float64(0) 256 | 257 | for j := 0; j < len(n.InputValues); j++ { 258 | sum += float64(n.InputValues[j]) * n.InputWeights[j][i] 259 | } 260 | 261 | n.HiddenOutputs[i] = sigmoid(sum) 262 | } 263 | } 264 | 265 | func (n *Network) calculateFinalOutputs() { 266 | for i := 0; i < n.NumOutputs; i++ { 267 | sum := float64(0) 268 | 269 | for j := 0; j < len(n.HiddenOutputs); j++ { 270 | val := n.HiddenOutputs[j] * n.OutputWeights[j][i] 271 | sum += val 272 | //log.Printf("val: %f", val) 273 | } 274 | 275 | //log.Printf("sum: %f", sum) 276 | n.OutputValues[i] = sigmoid(sum) 277 | } 278 | } 279 | 280 | // function that maps its input to a range between 0..1 281 | // mathematically it's supposed to be asymptotic, but large values of x may round up to 1 282 | func sigmoid(x float64) float64 { 283 | return 1.0 / (1.0 + math.Exp(-x)) 284 | } 285 | 286 | // map a rune char to an array of int, representing its unicode codepoint in binary 287 | // 'A' => 65 => []int {0, 1, 0, 0, 0, 0, 0, 1} 288 | // result is zero-padded to n.NumOutputs 289 | // 290 | func (n *Network) runeToArrayOfInts(r rune) []int { 291 | var result []int = make([]int, n.NumOutputs) 292 | 293 | codePoint := int64(r) // e.g. 65 294 | 295 | // we want to pad with n.NumOutputs number of zeroes, so create a dynamic format for Sprintf 296 | format := fmt.Sprintf("%%0%db", n.NumOutputs) 297 | binaryString := fmt.Sprintf(format, codePoint) // e.g. "01000001" 298 | 299 | // must use range: array indexing of strings returns bytes 300 | for i, v := range binaryString { 301 | if v == '0' { 302 | result[i] = 0 303 | } else { 304 | result[i] = 1 305 | } 306 | } 307 | return result 308 | } 309 | -------------------------------------------------------------------------------- /network_test.go: -------------------------------------------------------------------------------- 1 | package gocarina 2 | 3 | import ( 4 | "io/ioutil" 5 | "reflect" 6 | "testing" 7 | ) 8 | 9 | func TestNetwork(t *testing.T) { 10 | n := NewNetwork(25 * 25) 11 | n.calculateHiddenOutputs() 12 | n.calculateOutputErrors('A') 13 | n.calculateFinalOutputs() 14 | n.calculateHiddenErrors() 15 | n.adjustOutputWeights() 16 | n.adjustInputWeights() 17 | } 18 | 19 | func TestSaveRestore(t *testing.T) { 20 | n := NewNetwork(25 * 25) 21 | n.assignRandomWeights() 22 | 23 | f, err := ioutil.TempFile("", "network") 24 | if err != nil { 25 | t.Fatal(err) 26 | } 27 | 28 | n.Save(f.Name()) 29 | restored, err := RestoreNetwork(f.Name()) 30 | if err != nil { 31 | t.Fatal(err) 32 | } 33 | 34 | if !reflect.DeepEqual(n, restored) { 35 | t.Fatalf("expected: %+v, got %+v", n, restored) 36 | } 37 | } 38 | 39 | func TestSigmoid(t *testing.T) { 40 | xvals := []float64{-100000.0, -10000.0, -1000.0, -100.0, -10.0, 0.0, 0.1, 0.01, 0.001, 1.0, 10.0, 100.0, 1000.0, 10000.0, 100000.0} 41 | 42 | for _, x := range xvals { 43 | y := sigmoid(x) 44 | 45 | if y < 0.0 || y > 1.0 { 46 | t.Fatalf("for input %f got %f, should be in (0..1.0)", x, y) 47 | } 48 | } 49 | } 50 | 51 | func TestRuneToArrayOfInts(t *testing.T) { 52 | n := NewNetwork(25 * 25) 53 | 54 | expected := []int{0, 1, 0, 0, 0, 0, 0, 1} 55 | actual := n.runeToArrayOfInts('A') 56 | 57 | if !reflect.DeepEqual(expected, actual) { 58 | t.Fatalf("expected: %+v, got: %+v", expected, actual) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /solver.go: -------------------------------------------------------------------------------- 1 | package gocarina 2 | 3 | import ( 4 | "bufio" 5 | "log" 6 | "os" 7 | "sort" 8 | "strings" 9 | ) 10 | 11 | // WordsFrom returns a slice of dictionary words that can be constructed from the given chars. 12 | func WordsFrom(chars string) []string { 13 | chars = strings.ToLower(chars) 14 | 15 | file, err := os.Open("words-en.txt") 16 | if err != nil { 17 | log.Fatal(err) 18 | } 19 | defer file.Close() 20 | 21 | var result []string 22 | 23 | // iterate every word in the file. NB: words are already lower-cased in the file. 24 | scanner := bufio.NewScanner(file) 25 | for scanner.Scan() { 26 | word := scanner.Text() 27 | if CanMakeWordFrom(word, chars) { 28 | result = append(result, word) 29 | } 30 | } 31 | 32 | if err := scanner.Err(); err != nil { 33 | log.Fatal(err) 34 | } 35 | 36 | sort.Sort(ByWordLength(result)) 37 | 38 | return result 39 | } 40 | 41 | // CanMakeWordFrom returns true if the characters from 'chars' can be re-ordered to form 'word', else false. 42 | // Leftover letters are OK, but individual letters cannot be re-used. If a given letter is needed multiple times 43 | // (e.g. 'door' needs two o's), then the letter must appear multiple times in 'chars'. 44 | func CanMakeWordFrom(word string, chars string) bool { 45 | pool := []rune(chars) 46 | 47 | // iterate every char in word, and take them one at a time from pool 48 | for _, c := range word { 49 | var ok bool 50 | 51 | if pool, ok = takeOne(pool, c); !ok { 52 | // couldn't find c in pool 53 | return false 54 | } 55 | } 56 | 57 | // found every letter 58 | return true 59 | } 60 | 61 | // takeOne will remove one instance of the given char from pool. It returns the (possibly modified) slice, 62 | // and a boolean to indicate whether the char was found or not. 63 | func takeOne(pool []rune, char rune) ([]rune, bool) { 64 | for i, c := range pool { 65 | if c == char { 66 | result := append(pool[:i], pool[i+1:]...) 67 | return result, true 68 | } 69 | } 70 | 71 | return pool, false 72 | } 73 | 74 | // Sort by word length descending, then alphabetical ascending. So bigger words come first, 75 | // but equal-length words are sub-sorted alphabetically. 76 | type ByWordLength []string 77 | 78 | func (w ByWordLength) Len() int { return len(w) } 79 | func (w ByWordLength) Swap(i, j int) { w[i], w[j] = w[j], w[i] } 80 | func (w ByWordLength) Less(i, j int) bool { 81 | ri := []rune(w[i]) 82 | rj := []rune(w[j]) 83 | 84 | // first sort on word-length, descending 85 | if len(ri) > len(rj) { 86 | return true 87 | } 88 | 89 | if len(ri) < len(rj) { 90 | return false 91 | } 92 | 93 | // lengths are equal, so sort secondarily by alphabet 94 | for i, _ := range ri { 95 | if ri[i] == rj[i] { 96 | continue 97 | } 98 | 99 | if ri[i] < rj[i] { 100 | return true 101 | } 102 | 103 | return false 104 | } 105 | 106 | // if we get here, all chars are equal, but "x" is still not < "x", so return false 107 | return false 108 | } 109 | -------------------------------------------------------------------------------- /solver_test.go: -------------------------------------------------------------------------------- 1 | package gocarina 2 | 3 | import ( 4 | "reflect" 5 | "sort" 6 | "testing" 7 | ) 8 | 9 | func TestCanMakeWordFrom(t *testing.T) { 10 | var examples = []struct { 11 | word string 12 | chars string 13 | out bool 14 | }{ 15 | {"launch", "launch", true}, 16 | {"lunch", "launch", true}, 17 | {"brunch", "launch", false}, 18 | {"aaa", "aaa", true}, 19 | {"aaaa", "aaa", false}, 20 | } 21 | 22 | for _, tt := range examples { 23 | expected := tt.out 24 | actual := CanMakeWordFrom(tt.word, tt.chars) 25 | 26 | if actual != expected { 27 | t.Errorf("error for %q, %q: wanted %t, got: %t", tt.word, tt.chars, expected, actual) 28 | } 29 | } 30 | } 31 | 32 | func TestWordsFrom(t *testing.T) { 33 | expected := []string{ 34 | "bare", "bear", "brae", "arb", "are", "bar", "bra", "ear", "era", "reb", "ab", "ae", "ar", "ba", "be", "ea", "er", "re", 35 | } 36 | 37 | actual := WordsFrom("BEAR") 38 | 39 | if !reflect.DeepEqual(expected, actual) { 40 | t.Fatalf("expected: %q, actual: %q", expected, actual) 41 | } 42 | } 43 | 44 | func TestSortByWordLength(t *testing.T) { 45 | var examples = []struct { 46 | in []string 47 | out []string 48 | }{ 49 | // bigger words first, but then alphabetical for equal word-lengths 50 | {[]string{"door", "dead", "darth", "dear", "apple", "a", "alpha", "zylophone", "beta", "bear"}, []string{"zylophone", "alpha", "apple", "darth", "bear", "beta", "dead", "dear", "door", "a"}}, 51 | } 52 | 53 | for _, tt := range examples { 54 | expected := tt.out 55 | 56 | // first make a copy, because sort.Sort() works in-place on the slice 57 | actual := make([]string, len(tt.in)) 58 | copy(actual, tt.in) 59 | sort.Sort(ByWordLength(actual)) 60 | 61 | if !reflect.DeepEqual(expected, actual) { 62 | t.Errorf("error for %q, wanted %q, got: %q", tt.in, expected, actual) 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /tile.go: -------------------------------------------------------------------------------- 1 | package gocarina 2 | 3 | import ( 4 | "fmt" 5 | "image" 6 | "image/png" 7 | "log" 8 | "os" 9 | ) 10 | 11 | // Tile represents a lettered square from a Letterpress game board. 12 | type Tile struct { 13 | Letter rune // the letter this tile represents, if known 14 | img image.Image // the original tile image, prior to any scaling/downsampling 15 | Reduced image.Image // the tile in black and white, bounding-boxed, and scaled down 16 | Bounded image.Image // the bounded tile (used only for debugging) 17 | } 18 | 19 | func NewTile(letter rune, img image.Image) (result *Tile) { 20 | result = &Tile{Letter: letter, img: img} 21 | result.reduce(0) 22 | 23 | return 24 | } 25 | 26 | // Reduce the tile by converting to monochrome, applying a bounding box, and scaling to match the given size. 27 | // The resulting image will be stored in t.Reduced. 28 | func (t *Tile) reduce(border int) { 29 | targetRect := image.Rect(0, 0, TileTargetWidth, TileTargetHeight) 30 | if targetRect.Dx() != TileTargetWidth { 31 | log.Fatalf("expected targetRect.Dx() to be %d, got: %d", TileTargetWidth, targetRect.Dx()) 32 | } 33 | 34 | if targetRect.Dy() != TileTargetHeight { 35 | log.Fatalf("expected targetRect.Dy() to be %d, got: %d", TileTargetHeight, targetRect.Dy()) 36 | } 37 | 38 | src := BlackWhiteImage(t.img) 39 | 40 | // find the bounding box for the character 41 | bbox := BoundingBox(src, border) 42 | 43 | // Only apply the bounding box if it's above some % of the width/height of original tile. 44 | // This is to avoid pathological cases for skinny letters like "I", which 45 | // would otherwise result in completely black tiles when bounded. 46 | 47 | if bbox.Bounds().Dx() >= int(MinBoundingBoxPercent*float64(t.img.Bounds().Dx())) && 48 | bbox.Bounds().Dy() >= int(MinBoundingBoxPercent*float64(t.img.Bounds().Dy())) { 49 | src = src.(interface { 50 | SubImage(r image.Rectangle) image.Image 51 | }).SubImage(bbox) 52 | } else { 53 | // enable only for debugging 54 | //log.Printf("rune: %c: skipping boundingbox: orig width: %d, boundbox width: %d", t.Letter, t.img.Bounds().Dx(), bbox.Dx()) 55 | } 56 | 57 | t.Bounded = src 58 | t.Reduced = Scale(src, targetRect) 59 | 60 | // it's sometimes helpful to see a textual version of the reduced tile 61 | //log.Printf("\n%s\n", ImageToString(t.Reduced)) 62 | 63 | if t.Reduced.Bounds().Dx() != TileTargetWidth { 64 | log.Fatalf("expected t.Reduced.Bounds().Dx() to be %d, got: %d", TileTargetWidth, t.Reduced.Bounds().Dx()) 65 | } 66 | 67 | if t.Reduced.Bounds().Dy() != TileTargetHeight { 68 | log.Fatalf("expected t.Reduced.Bounds().Dy() to be %d, got: %d", TileTargetHeight, t.Reduced.Bounds().Dy()) 69 | } 70 | 71 | } 72 | 73 | // Save the bounded tile. Only for debugging. 74 | func (t *Tile) SaveBoundedAndReduced() { 75 | saveImgToFile := func(file string, img image.Image) { 76 | toFile, err := os.Create(file) 77 | if err != nil { 78 | log.Fatal(err) 79 | } 80 | defer toFile.Close() 81 | 82 | err = png.Encode(toFile, img) 83 | if err != nil { 84 | log.Fatal(err) 85 | } 86 | } 87 | 88 | saveImgToFile(fmt.Sprintf("debug_output/bounded_%c.png", t.Letter), t.Bounded) 89 | saveImgToFile(fmt.Sprintf("debug_output/reduced_%c.png", t.Letter), t.Reduced) 90 | } 91 | --------------------------------------------------------------------------------