typistapp/
model.rs

1use ab_glyph::FontArc;
2use anyhow::{Result, bail};
3use image::{DynamicImage, imageops};
4use log;
5use rayon::iter::{IntoParallelRefIterator, IntoParallelRefMutIterator, ParallelIterator};
6
7use crate::correlation::correlation;
8use crate::element::Element;
9use crate::{FULL_WIDTH_SPACE, GLYPH_SCALE, IMAGE_SIZE, NUM_OF_CANDIDATES};
10
11/// A struct that serves as the Model (M) in MVC. Specializes in data management.
12/// Converts an image into typist-art using a set of full-width characters and a font.
13#[derive(Debug, Clone)]
14pub struct Model {
15    /// The source image to be converted to typist-art.
16    image: DynamicImage,
17
18    /// A collection of full-width characters used for rendering the art.
19    characters: Vec<char>,
20
21    /// The font used to render each character.
22    font: FontArc,
23
24    /// The number of characters (columns) per line in the output art.
25    columns: u32,
26
27    /// The total number of lines (rows) in the output art.
28    lines: u32,
29}
30
31impl Model {
32    /// Creates a new Model instance with a resized image and initialized parameters.
33    pub fn new(
34        length: u32,
35        image: &DynamicImage,
36        characters: &[char],
37        font: &[u8],
38    ) -> Result<Self> {
39        let columns = length;
40        let width = IMAGE_SIZE * columns;
41        let height = image.height() * width / image.width();
42        let img = image.resize(width, height, imageops::FilterType::Triangle);
43        let lines = height / IMAGE_SIZE;
44        log::info!(
45            "Image dimensions: {width}x{height}, size: {IMAGE_SIZE}, columns: {columns}, lines: {lines}",
46        );
47        let font = match FontArc::try_from_vec(font.to_vec()) {
48            Ok(f) => f,
49            Err(e) => bail!("Failed to load font: {}", e),
50        };
51
52        Ok(Model {
53            image: img,
54            characters: characters.to_vec(),
55            font,
56            columns,
57            lines,
58        })
59    }
60
61    /// Converts the input image into a vector of typist-art strings.
62    pub fn convert(&mut self) -> Result<Vec<String>> {
63        let typeset_elements = self.typeset_elements(&self.characters)?;
64        let picture_elements =
65            self.picture_elements(&self.image, IMAGE_SIZE, self.columns, self.lines)?;
66        log::info!(
67            "Typeset elements: {}, Picture elements: {}",
68            typeset_elements.len(),
69            picture_elements.len()
70        );
71
72        let typist_art_elements = Self::generate_typist_art(&picture_elements, &typeset_elements);
73        log::info!("Converted picture elements to typist art.");
74
75        let mut result = vec![];
76        let mut v = vec![];
77        for (i, e) in typist_art_elements.iter().enumerate() {
78            if i % self.columns as usize == 0 && i != 0 {
79                result.push(v.iter().collect());
80                v.clear();
81            }
82            v.push(e.character().unwrap_or(FULL_WIDTH_SPACE));
83        }
84        if !v.is_empty() {
85            result.push(v.iter().collect());
86        }
87
88        Ok(result)
89    }
90
91    /// Divides the input image into a grid of picture elements (tiles),
92    /// computes their luminance characteristics, and normalizes them.
93    fn picture_elements(
94        &self,
95        image: &DynamicImage,
96        size: u32,
97        columns: u32,
98        lines: u32,
99    ) -> Result<Vec<Element>> {
100        let mut elements = vec![];
101        for y in 0..lines {
102            for x in 0..columns {
103                let block_image = image.crop_imm(x * size, y * size, size, size);
104                elements.push(Element::from_image(block_image)?);
105            }
106        }
107
108        // normalize the luminance of the picture elements.
109        Self::normalize_elements(&mut elements)?;
110
111        Ok(elements)
112    }
113
114    /// Renders each character into an image using the given font, converts
115    /// them into elements, normalizes their luminance, and sorts them by brightness.
116    fn typeset_elements(&self, characters: &[char]) -> Result<Vec<Element>> {
117        let mut elements: Vec<Element> = characters
118            .par_iter()
119            .map(|c| Element::from_char(&self.font, *c, *GLYPH_SCALE))
120            .collect::<Result<Vec<_>>>()?;
121
122        // normalize the luminance of the typeset elements.
123        Self::normalize_elements(&mut elements)?;
124
125        // sort the typeset elements by luminance.
126        elements.sort_by(|a, b| {
127            a.luminance()
128                .partial_cmp(&b.luminance())
129                .unwrap_or(std::cmp::Ordering::Equal)
130        });
131        log::debug!("Sorted typeset elements by luminance.");
132        for e in &elements {
133            log::debug!(
134                "Character: {:?}, Luminance: {}",
135                e.character(),
136                e.luminance(),
137            );
138        }
139
140        Ok(elements)
141    }
142
143    /// Normalizes the luminance and pixel characteristics of each element
144    /// so that all values are within a common range.
145    fn normalize_elements(elements: &mut [Element]) -> Result<()> {
146        let mut min = f64::INFINITY;
147        let mut max = f64::NEG_INFINITY;
148        for e in elements.iter_mut() {
149            if e.luminance() < min {
150                min = e.luminance();
151            }
152            if e.luminance() > max {
153                max = e.luminance();
154            }
155        }
156        log::info!("Luminance range: [{min}, {max}]");
157
158        elements
159            .par_iter_mut()
160            .for_each(|e| e.normalized(min, max).unwrap());
161        log::info!("Normalized elements.");
162
163        Ok(())
164    }
165
166    /// Finds the index of the element in the typeset list whose luminance is
167    /// closest to the given target luminance value.
168    fn closest_luminance_index(target: f64, typeset_elements: &[Element]) -> usize {
169        let result = typeset_elements.binary_search_by(|prove| {
170            prove
171                .luminance()
172                .partial_cmp(&target)
173                .unwrap_or(std::cmp::Ordering::Equal)
174        });
175
176        match result {
177            Ok(i) => i,
178            Err(i) => {
179                if i == 0 {
180                    0
181                } else if i >= typeset_elements.len() {
182                    typeset_elements.len() - 1
183                } else {
184                    let diff1 = (typeset_elements[i - 1].luminance() - target).abs();
185                    let diff2 = (typeset_elements[i].luminance() - target).abs();
186                    if diff1 < diff2 { i - 1 } else { i }
187                }
188            }
189        }
190    }
191
192    /// Selects the best-matching element from the given candidates
193    /// based on pixel-wise correlation similarity.
194    fn best_match_element<'a>(target: &Element, candidates: &'a [Element]) -> Option<&'a Element> {
195        let mut max = -1.0;
196        let mut best: Option<&Element> = None;
197        for candidate in candidates {
198            if let Some(result) = correlation(target.characteristics(), candidate.characteristics())
199            {
200                if result > max {
201                    max = result;
202                    best = Some(candidate);
203                }
204            }
205        }
206
207        best
208    }
209
210    /// Finds the best-matching character element for a picture element
211    /// by combining luminance-based preselection and pixel correlation.
212    fn search_typeset_element<'a>(
213        picture_element: &'a Element,
214        typeset_elements: &'a [Element],
215    ) -> Option<&'a Element> {
216        if typeset_elements.is_empty() {
217            return None;
218        }
219
220        // STEP 1: find the index of the character with the most similar average luminance.
221        let index = Self::closest_luminance_index(picture_element.luminance(), typeset_elements);
222
223        // STEP 2: create a slice of candidates around that index for a more detailed search.
224        // NOTE: use saturating_sub to avoid underflow when index is less than NUM_OF_CANDIDATES / 2.
225        let from = index.saturating_sub(NUM_OF_CANDIDATES / 2);
226        let to = std::cmp::min(typeset_elements.len(), from + NUM_OF_CANDIDATES);
227        let candidates = &typeset_elements[from..to];
228
229        if candidates.is_empty() {
230            return Some(&typeset_elements[index]);
231        }
232
233        // STEP 3: from the candidates, find the best match using pixel-by-pixel correlation.
234        Self::best_match_element(picture_element, candidates)
235    }
236
237    /// Converts the picture elements into their best-matching character elements
238    /// to generate the final typist-art structure.
239    fn generate_typist_art(
240        picture_elements: &[Element],
241        typeset_elements: &[Element],
242    ) -> Vec<Element> {
243        let default = Element::default();
244        let typist_art_elements: Vec<Element> = picture_elements
245            .par_iter()
246            .map(|e| Self::search_typeset_element(e, typeset_elements).unwrap_or(&default))
247            .cloned()
248            .collect();
249
250        typist_art_elements
251    }
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    #[test]
259    fn closest_luminance_index_empty_elements() {
260        let elements: Vec<Element> = vec![];
261        assert_eq!(Model::closest_luminance_index(0.5, &elements), 0);
262    }
263
264    #[test]
265    fn closest_luminance_index_single_element() {
266        let elements = vec![Element::new(vec![0.0; 10], 0.5, Some('A'), None)];
267        assert_eq!(Model::closest_luminance_index(0.5, &elements), 0);
268    }
269
270    #[test]
271    fn closest_luminance_index_multiple_elements() {
272        let elements = vec![
273            Element::new(vec![0.0; 10], 0.1, None, None),
274            Element::new(vec![0.0; 10], 0.5, None, None),
275            Element::new(vec![0.0; 10], 0.9, None, None),
276        ];
277        assert_eq!(Model::closest_luminance_index(0.5, &elements), 1);
278        assert_eq!(Model::closest_luminance_index(0.2, &elements), 0);
279        assert_eq!(Model::closest_luminance_index(0.8, &elements), 2);
280    }
281
282    #[test]
283    fn best_match_element_empty_candidates() {
284        let target = Element::new(vec![0.5; 10], 0.5, Some('A'), None);
285        let candidates: Vec<Element> = vec![];
286        assert!(Model::best_match_element(&target, &candidates).is_none());
287    }
288
289    #[test]
290    fn best_match_element_valid_candidates() {
291        let target = Element::new(vec![0.5; 10], 0.5, Some('A'), None);
292        let candidates = vec![
293            Element::new(vec![0.2; 10], 0.2, Some('B'), None),
294            Element::new(vec![0.5; 10], 0.5, Some('C'), None),
295            Element::new(vec![0.7; 10], 0.7, Some('D'), None),
296        ];
297        let best = Model::best_match_element(&target, &candidates);
298        assert!(best.is_some());
299        assert_eq!(best.unwrap().characteristics(), &vec![0.5; 10]);
300    }
301
302    #[test]
303    fn search_typeset_element_empty_typeset_returns_none() {
304        let picture_element = Element::new(vec![0.0; 10], 0.5, Some('A'), None);
305        let typeset_elements: Vec<Element> = vec![];
306        assert!(Model::search_typeset_element(&picture_element, &typeset_elements).is_none());
307    }
308
309    #[test]
310    fn search_typeset_element_valid_typeset_returns_some() {
311        let picture_element = Element::new(vec![0.5; 10], 0.5, Some('A'), None);
312        let typeset_elements = vec![
313            Element::new(vec![0.2; 10], 0.2, Some('B'), None),
314            Element::new(vec![0.2; 10], 0.2, Some('B'), None),
315            Element::new(vec![0.5; 10], 0.5, Some('C'), None),
316            Element::new(vec![0.7; 10], 0.7, Some('D'), None),
317        ];
318        let result = Model::search_typeset_element(&picture_element, &typeset_elements);
319        assert!(result.is_some());
320        let best_match = result.unwrap();
321        assert_eq!(best_match.characteristics(), &vec![0.5; 10]);
322        assert_eq!(best_match.character(), Some('C'));
323    }
324}