Skip to main content

wordfeud_ocr/
recognizer.rs

1use crate::layout::{Layout, THRESHOLD};
2use crate::Error;
3use image::imageops::{resize, FilterType};
4use image::math::Rect;
5use image::{GenericImageView, GrayImage};
6use imageproc::contrast::threshold;
7use imageproc::template_matching::{find_extremes, match_template, MatchTemplateMethod};
8use std::fmt;
9use std::ops::{Deref, DerefMut};
10
11/// Recognized letters or bonus squares, organized as a two-dimensional grid.
12#[derive(Debug, Clone, Default)]
13pub struct Ocr(pub Vec<Vec<String>>);
14
15impl Deref for Ocr {
16    type Target = Vec<Vec<String>>;
17    fn deref(&self) -> &Self::Target {
18        &self.0
19    }
20}
21
22impl DerefMut for Ocr {
23    fn deref_mut(&mut self) -> &mut Self::Target {
24        &mut self.0
25    }
26}
27
28/// A list of [OcrStat](crate::OcrStat). Can be used to analyze the accuracy of template matching.
29pub type OcrStats = Vec<OcrStat>;
30
31/// Results for a single template match
32#[derive(Debug, Clone, Default)]
33pub struct OcrStat {
34    /// The linear cell index (0.. nrows * ncols)
35    index: usize,
36    /// The tag of the matched template
37    tag: String,
38    /// The match error (the minimum value of all matched templates)
39    min_value: f32,
40    /// The location where the best template match was found
41    min_value_location: (u32, u32),
42}
43/// Holds the result of recognize_screenshot: recognized tiles on the board and rack, plus grid with bonus squares.
44#[derive(Debug, Clone)]
45pub struct OcrResults {
46    /// The tiles on the board
47    pub tiles_ocr: Ocr,
48    /// The grid with bonus squares
49    pub grid_ocr: Ocr,
50    /// The tiles on the rack
51    pub rack_ocr: Ocr,
52    /// Stats for tile recognition
53    pub tiles_stats: OcrStats,
54    /// Stats for grid recognition
55    pub grid_stats: OcrStats,
56    /// Stats for rack recognition
57    pub rack_stats: OcrStats,
58    /// Board area bounding rectangle
59    pub board_area: Rect,
60    /// Rack area bounding rectangle
61    pub rack_area: Rect,
62}
63
64impl fmt::Display for Ocr {
65    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
66        let ocr_string = self
67            .iter()
68            .map(|v| v.join(""))
69            .collect::<Vec<String>>()
70            .join("\n");
71        write!(f, "{}", ocr_string)
72    }
73}
74
75impl OcrResults {}
76
77const START_SQUARE: usize = 15 * 7 + 7;
78
79/// The templates! macro embeds the templates in the library/
80macro_rules! templates {
81    ( $( $x:expr ),* ) => {
82            [$(
83                   ($x, include_bytes!(concat!("templates/", $x, ".png"))),
84            )*]
85        };
86}
87
88const LETTER_TEMPLATES: &[(&str, &[u8])] = &templates![
89    "A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S",
90    "T", "U", "V", "W", "X", "Y", "Z", "Æ", "Å", "Ä", "Ñ", "Ö", "Ø", "CH", "LL", "RR"
91];
92
93const BONUS_TEMPLATES: &[(&str, &[u8])] = &templates!["2L", "3L", "2W", "3W"];
94
95fn template_from_buffer(name: &str, buf: &[u8]) -> (String, GrayImage) {
96    (
97        String::from(name),
98        image::load_from_memory(buf).unwrap().to_luma8(), // can not fail because the templates are embedded
99    )
100}
101
102/// Wordfeud board recognizer
103pub struct Board {
104    pub templates: Vec<(String, GrayImage)>,
105    pub bonus_templates: Vec<(String, GrayImage)>,
106}
107
108impl Default for Board {
109    fn default() -> Self {
110        Board::new()
111    }
112}
113
114impl Board {
115    pub fn new() -> Board {
116        let templates = LETTER_TEMPLATES
117            .iter()
118            .map(|(name, buf)| template_from_buffer(name, buf))
119            .collect();
120        let bonus_templates = BONUS_TEMPLATES
121            .iter()
122            .map(|(name, buf)| template_from_buffer(name, buf))
123            .collect();
124        Board {
125            templates,
126            bonus_templates,
127        }
128    }
129
130    /// Recognize a wordfeud board screenshot.
131    ///
132    /// Returns a result that contains the detected tiles on the board and in the rack, and the detected board with
133    /// the bonus tiles locations.
134    /// The recognition process consists of these phases:
135    /// 1. Segmentation of the board: find the board and rack area, and locate the cells on the board and the rack
136    /// 2. Use template matching to recognize the tiles and bonus squares
137    ///
138    /// # Errors
139    /// * The screenshot can not be segmented properly
140    ///
141    pub fn recognize_screenshot(&self, screenshot: &GrayImage) -> Result<OcrResults, Error> {
142        let layout = Layout::new(&screenshot).segment()?;
143
144        let cells = Layout::get_cells(&layout.rows, &layout.cols);
145        // println!("{:?}", cells);
146        let tile_index = layout.get_tile_index(&cells);
147        let (tiles_ocr, tiles_stats) = self.recognize_tiles(
148            screenshot,
149            &layout,
150            &tile_index,
151            &cells,
152            &self.templates,
153            (15, 15),
154        );
155
156        let (grid_ocr, grid_stats) =
157            self.recognize_board(screenshot, &layout, &cells, &self.bonus_templates, (15, 15));
158
159        let cells = Layout::get_cells(&layout.rack_rows, &layout.rack_cols);
160        let index: Vec<usize> = (0..cells.len()).into_iter().collect();
161        let (rack_ocr, rack_stats) =
162            self.recognize_tiles(screenshot, &layout, &index, &cells, &self.templates, (1, 7));
163
164        let res = OcrResults {
165            tiles_ocr,
166            grid_ocr,
167            rack_ocr,
168            tiles_stats,
169            grid_stats,
170            rack_stats,
171            board_area: layout.board_area,
172            rack_area: layout.rack_area,
173        };
174
175        Ok(res)
176    }
177
178    pub fn recognize_screenshot_from_file(
179        &self,
180        screenshot_filename: &str,
181    ) -> Result<OcrResults, Error> {
182        let gray = image::open(&screenshot_filename)?.into_luma8();
183        self.recognize_screenshot(&gray)
184    }
185
186    pub fn recognize_screenshot_from_memory(&self, screenshot: &[u8]) -> Result<OcrResults, Error> {
187        let gray = image::load_from_memory(&screenshot)?.into_luma8();
188        self.recognize_screenshot(&gray)
189    }
190
191    fn topright(cell: Rect) -> Rect {
192        Rect {
193            x: cell.x + (0.73 * cell.width as f64).round() as u32,
194            y: cell.y + (0.06 * cell.height as f64).round() as u32,
195            width: (0.18 * cell.width as f64).round() as u32,
196            height: (0.27 * cell.height as f64).round() as u32,
197        }
198    }
199
200    fn recognize_tiles(
201        &self,
202        img: &GrayImage,
203        layout: &Layout,
204        tile_index: &[usize],
205        cells: &[Rect],
206        templates: &[(String, GrayImage)],
207        size: (usize, usize),
208    ) -> (Ocr, OcrStats) {
209        // create rows x cols empty grid
210        let (rows, cols) = size;
211        let row: Vec<String> = (0..cols).into_iter().map(|_| String::from(".")).collect();
212        let mut ocr = Ocr((0..rows)
213            .into_iter()
214            .map(|_| row.clone())
215            .collect::<Vec<_>>());
216        if tile_index.is_empty() {
217            println!("No tiles");
218            return (ocr, Vec::new());
219        }
220
221        let mut stats = Vec::new();
222        let thresh = (THRESHOLD * 256.) as u8;
223        for &index in tile_index.iter() {
224            let cell = cells[index];
225
226            // check if the tile is a blank (in the rack)
227            let (mean, std) = layout.area_stats(&cell);
228            let is_blank = mean > 0.9 && std < 0.2;
229
230            // check if the tile is a wildcard
231            let topright = Board::topright(cell);
232            let (mean, std) = layout.area_stats(&topright);
233            let is_wildcard = mean > 0.8 && std < 0.1;
234
235            // create tile image
236            let mut tile: GrayImage = img.view(cell.x, cell.y, cell.width, cell.height).to_image();
237            // convert to binary image improves the template match accurarcy
238            tile = threshold(&tile, thresh);
239
240            if tile.width() > 67 {
241                tile = resize(&tile, 67, 67, FilterType::Lanczos3);
242            }
243
244            // Area for template matching. Cell dimension is 67 square
245            // Template dimension is wxh = 38 x 50
246            let area = tile.view(6, 3, 40, 62).to_image();
247
248            // match templates
249            let (letter, min_value, min_value_location) = if !is_blank {
250                Board::match_template(&area, templates)
251            } else {
252                (String::from("*"), 0.0_f32, (0_u32, 0_u32))
253            };
254            let (row, col) = (index / cols, index % cols);
255            ocr[row][col] = if !is_wildcard {
256                letter.to_lowercase()
257            } else {
258                letter.clone()
259            };
260            stats.push(OcrStat {
261                index,
262                tag: letter.clone(),
263                min_value,
264                min_value_location,
265            });
266        }
267        (ocr, stats)
268    }
269
270    fn recognize_board(
271        &self,
272        img: &GrayImage,
273        layout: &Layout,
274        cells: &[Rect],
275        templates: &[(String, GrayImage)],
276        size: (usize, usize),
277    ) -> (Ocr, OcrStats) {
278        // create rows x cols empty grid
279        let (rows, cols) = size;
280
281        let row: Vec<String> = (0..cols).into_iter().map(|_| String::from("--")).collect();
282        let mut ocr = Ocr((0..rows)
283            .into_iter()
284            .map(|_| row.clone())
285            .collect::<Vec<_>>());
286        ocr[7][7] = String::from("ss"); // start square
287        let mut stats = Vec::new();
288        for (index, cell) in cells.iter().enumerate() {
289            let mean = layout.mean(&cell);
290            if mean > THRESHOLD || mean < 0.25 || index == START_SQUARE {
291                continue;
292            }
293
294            // create tile image
295            let tile: GrayImage = img.view(cell.x, cell.y, cell.width, cell.height).to_image();
296
297            // Area for template matching. Cell dimension is wxh = 67 x 67.
298            // Template dimension is wxh = 46x46
299            let area = tile.view(8, 21, 48, 28).to_image();
300
301            // // match templates
302            let (letter, min_value, min_value_location) = Board::match_template(&area, templates);
303            let (row, col) = (index / cols, index % cols);
304            ocr[row][col] = letter.to_lowercase();
305            stats.push(OcrStat {
306                index,
307                tag: letter.to_lowercase(),
308                min_value,
309                min_value_location,
310            });
311        }
312        (ocr, stats)
313    }
314
315    fn match_template(
316        tile: &GrayImage,
317        templates: &[(String, GrayImage)],
318    ) -> (String, f32, (u32, u32)) {
319        let method = MatchTemplateMethod::SumOfSquaredErrorsNormalized;
320        let mut matches = templates
321            .iter()
322            .map(|(letter, template)| {
323                (
324                    letter.clone(),
325                    find_extremes(&match_template(&tile, &template, method)),
326                )
327            })
328            .collect::<Vec<_>>();
329        // find the best match
330        matches.sort_by(|a, b| a.1.min_value.partial_cmp(&b.1.min_value).unwrap());
331        let (letter, extreme) = matches[0].clone();
332        (letter, extreme.min_value, extreme.min_value_location)
333    }
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339
340    #[test]
341    fn test_topright() {
342        let cell = Rect {
343            x: 0,
344            y: 0,
345            width: 67,
346            height: 67,
347        };
348        let topright = Board::topright(cell);
349        println!("{:?} {:?}", cell, topright);
350        assert_eq!(
351            topright,
352            Rect {
353                x: 49,
354                y: 4,
355                width: 12,
356                height: 18
357            }
358        );
359    }
360}