typographer/
lib.rs

1//! Advanced ASCII art generator. Key features:
2//! - Converts pixel luminance to ASCII characters.
3//! - Uses smart Canny edge-detection to improve feature readability.
4//! - Has customizable colour modes. 
5//! - Compensates for the character aspect-ratio. 
6//! - Highly configurable. 
7//!
8//! Also available as a CLI on [GitHub](https://github.com/user-simon/typographer). 
9//! 
10//! # Basic Usage
11//!
12//! The [`Typographer`] algorithm is composed of three sub-algorithms:
13//! - [`Fill`]: computes fill symbols from the gradient using pixel lumas. 
14//! - [`Edges`]: computes symbols along the feature edges of the image. 
15//! - [`Colours`]: computes symbol colours using one of several modes. 
16//!
17//! The algorithm is then ran with [`Typographer::render`], which returns an [`AsciiImage`] represented as a
18//! 2D array of (possibly) styled symbols. 
19//! 
20//! Run with default settings: 
21//! ```no_run
22//! use typographer::{Typographer, Size, RgbImage};
23//!
24//! let typographer = Typographer::new_coloured();
25//! // let img: RgbImage
26//! # let img = RgbImage::default();
27//! let ascii: AsciiImage = typographer.render(img, Size::Width(100));
28//!
29//! for symbol in ascii.iter() {
30//!     print!(symbol);
31//! }
32//! ```
33//!
34//! Run with custom settings:
35//! ```no_run
36//! use typographer::*;
37//!
38//! let fill = Fill {
39//!     gradient: "   .oO",
40//!     ..Fill::default()
41//! };
42//! let edges = Edges {
43//!     angles: ['|', '/', '-', '\\'], 
44//!     sigma: 0.2,
45//!     ..Edges::default()
46//! };
47//! let typographer = Typographer {
48//!     luma_threshold: 0.4, 
49//!     symbol_aspect_ratio: 2.2, 
50//!     fill: Some(fill), 
51//!     edges: Some(edges), 
52//!     colours: None, // disable colours
53//!     ..Typographer::default()
54//! };
55//! 
56//! # let img = RgbImage::default();
57//! // let img: RgbImage
58//! let ascii: AsciiImage = typographer.render(img, Size::Stretched(100, 200));
59//!
60//! for symbol in ascii.iter() {
61//!     print!(symbol);
62//! }
63//! ```
64
65use std::{cell::LazyCell};
66use crossterm::style::{StyledContent, Stylize};
67use image::{imageops, Luma, Pixel};
68use itertools::Itertools;
69use palette::{Hsv, IntoColor};
70
71/// A "pixel" of the ASCII image. This has an aspect ratio defined by [`Typographer::symbol_aspect_ratio`]. 
72pub type Styled = StyledContent<char>;
73
74/// Input image with RGB pixels. 
75pub type RgbImage = image::RgbImage;
76
77/// Algorithm used to downsample images. 
78pub type Sampler = imageops::FilterType;
79
80/// Typographer entry-point.
81///
82/// By default, this always produces an empty image. To add output, define [`Typographer::fill`] and/or
83/// [`Typographer::edges`]. For full configurability, this could be done with the struct update syntax:
84/// 
85/// ```
86/// let typographer = Typographer {
87///     char_aspect_ratio: 1.5, 
88///     fill: Some(Fill {
89///         gradient: " `.:-=;*?+#%@".toString(), 
90///         brightness_gain: 0.1, 
91///         ..Default::default()
92///     }), 
93///     edges: Some(Edges::default()), 
94///     ..Default::default()
95/// };
96/// ```
97///
98/// ... or using the using the [`Typographer::new`] and [`Typographer::new_coloured`] short-hands.
99///
100/// Images are then rendered using [`Typographer::render`], provided an input image and output size in
101/// symbols. 
102#[derive(Clone)]
103pub struct Typographer {
104    /// Algorithm used to downsample the image. 
105    pub sampler: Sampler, 
106    /// The aspect ratio `height / width` of the symbols. This is usually around `2.0`. 
107    pub symbol_aspect_ratio: f32, 
108    /// Luma threshold to reduce noise. Defined in `[0, 1]`. 
109    pub luma_threshold: f32, 
110    /// Settings used to render luma. 
111    pub fill: Option<Fill>,
112    /// Settings used to render edges. 
113    pub edges: Option<Edges>,
114    /// Settings used to colourise the symbols. 
115    pub colours: Option<Colours>, 
116}
117
118impl Default for Typographer {
119    fn default() -> Self {
120        Typographer {
121            sampler: Sampler::Nearest, 
122            symbol_aspect_ratio: 2.0, 
123            luma_threshold: 0.05, 
124            fill: Some(Fill::default()),
125            edges: Some(Edges::default()), 
126            colours: None, 
127        }
128    }
129}
130
131impl Typographer {
132    /// Constructs with only fill and edges enabled. Each have default settings. 
133    pub fn new() -> Typographer {
134        Typographer {
135            fill: Some(Fill::default()),
136            edges: Some(Edges::default()), 
137            colours: None, 
138            ..Default::default()
139        }
140    }
141
142    /// Constructs with fill, edges, and colours enabled. Each have default settings. 
143    pub fn new_coloured() -> Typographer {
144        Typographer {
145            fill: Some(Fill::default()),
146            edges: Some(Edges::default()), 
147            colours: Some(Colours::default()), 
148            ..Default::default()
149        }
150    }
151    
152    /// Renders an RGB image to an ASCII image of given size in symbols. 
153    pub fn render(&self, image: &RgbImage, size: Size) -> AsciiImage {
154        // get aspect ratio preserving output width and height. this also corrects for the symbol
155        // aspect ratio by stretching such that the image is unstretched when the symbols are rendered in e.g.
156        // the terminal
157        let (width, height) = {
158            let aspect_ratio = image.height() as f32 / image.width() as f32;
159            let scale = |a, b| ((a as f32) * b) as u32;
160
161            match size {
162                Size::Width(w) => (w, scale(w, aspect_ratio / self.symbol_aspect_ratio)), 
163                Size::Height(h) => (scale(h, self.symbol_aspect_ratio / aspect_ratio), h), 
164                Size::Stretched(w, h) => (w, h), 
165            }
166        };
167
168        // downscale image and apply the threshold
169        let image = {
170            // we don't want to perform the expensive edge-detection on the full-sized image, so we downscale
171            // it to a multiple of the output size, clamping to 8 multiples (yielding 64:1 input:output
172            // pixels)
173            let clamped_multiples = |a, b| a * u32::clamp(b / a, 1, 8);
174            let width = clamped_multiples(width, image.width());
175            let height = clamped_multiples(height, image.height());
176            let mut image = imageops::resize(image, width, height, self.sampler);
177
178            for rgb in image.pixels_mut() {
179                const STRENGTH: i32 = 4;
180                *rgb = transform_hsv(rgb, |mut hsv| {
181                    hsv.value = match hsv.value < self.luma_threshold {
182                        true => hsv.value.powi(STRENGTH) / self.luma_threshold.powi(STRENGTH - 1),
183                        false => hsv.value,
184                    };
185                    hsv
186                });
187            }
188            image
189        };
190
191        let output_sized = LazyCell::new(|| imageops::resize(&image, width, height, self.sampler));
192
193        let mut ascii = AsciiImage::new(width, height);
194
195        if let Some(fill) = &self.fill {
196            ascii = fill.apply(LazyCell::force(&output_sized), ascii);
197        }
198        if let Some(edges) = &self.edges {
199            ascii = edges.apply(&image, ascii, self.symbol_aspect_ratio); 
200        }
201        if let Some(colour) = &self.colours {
202            ascii = colour.apply(LazyCell::force(&output_sized), ascii);
203        }
204        ascii
205    }
206}
207
208/// Represents a rendered ASCII image. 
209///
210/// Consists of a matrix of [`Styled`]. 
211#[derive(Clone)]
212pub struct AsciiImage {
213    symbols: Vec<Styled>, 
214    width: u32, 
215    height: u32, 
216}
217
218impl AsciiImage {
219    fn new(width: u32, height: u32) -> AsciiImage {
220        AsciiImage {
221            symbols: vec![' '.stylize(); (width * height) as usize], 
222            width, 
223            height, 
224        }
225    }
226    
227    /// Applies a generic pass over each symbol in the image. 
228    fn with_pass<T>(mut self, iter: impl Iterator<Item = T>, mut apply: impl FnMut(Styled, T) -> Styled) -> Self {
229        for (symbol, x) in std::iter::zip(self.symbols.iter_mut(), iter) {
230            *symbol = apply(*symbol, x);
231        }
232        self
233    }
234
235    /// Returns an iterator of image rows. 
236    pub fn iter_rows(&self) -> impl Iterator<Item = &[Styled]> {
237        self.symbols.chunks(self.width as usize)
238    }
239
240    /// Returns a flat iterator of symbols for printing. Each row is appended a newline. 
241    pub fn iter(&self) -> impl Iterator<Item = Styled> {
242        self.iter_rows()
243            .map(|row| row.iter()
244                .cloned()
245                .chain(std::iter::once('\n'.stylize()))
246            )
247            .flatten()
248    }
249
250    /// Returns a flat iterator of symbols for printing, with discarded colour information. Each row is
251    /// appended a newline. 
252    pub fn iter_unstyled(&self) -> impl Iterator<Item = char> {
253        self.iter().map(|symbol| symbol.content().clone())
254    }
255
256    /// Returns a flat iterator of symbols. 
257    pub fn iter_symbols(&self) -> impl Iterator<Item = Styled> {
258        self.symbols.iter().cloned()
259    }
260
261    /// Returns a flat iterator of symbols, with discarded colour information. 
262    pub fn iter_symbols_unstyled(&self) -> impl Iterator<Item = char> {
263        self.symbols.iter().map(Styled::content).cloned()
264    }
265
266    /// Returns the image width. 
267    pub fn width(&self) -> u32 {
268        self.width
269    }
270
271    /// Returns the image height. 
272    pub fn height(&self) -> u32 {
273        self.height
274    }
275    
276    /// Returns the internal flat buffer used to store the symbols. 
277    pub fn into_inner(self) -> Vec<Styled> {
278        self.symbols
279    }
280}
281
282/// Specifies the width/height of the output image, in symbols. 
283#[derive(Clone, Copy)]
284pub enum Size {
285    /// Specifies ASCII image width. The height is computed to preserve aspect ratio. 
286    Width(u32), 
287    /// Specifies ASCII image height. The width is computed to preserve aspect ratio. 
288    Height(u32), 
289    /// Specifies ASCII image width and height. Note that the output image may have a stretched aspect ratio. 
290    Stretched(u32, u32), 
291}
292
293/// Settings used when computing fill symbols from lumas. 
294#[derive(Clone)]
295pub struct Fill {
296    /// Symbols mapped from low to high luma. 
297    pub gradient: String, 
298    /// Reverses the order of the gradient for light backgrounds. 
299    pub light_mode: bool, 
300}
301
302impl Default for Fill {
303    fn default() -> Fill {
304        Fill {
305            gradient: " .:coPO?@■".to_string(), 
306            light_mode: false, 
307        }
308    }
309}
310
311impl Fill {
312    fn apply(&self, input: &RgbImage, ascii: AsciiImage) -> AsciiImage {
313        debug_assert!(input.width() == ascii.width && input.height() == ascii.height);
314
315        if self.gradient.is_empty() {
316            return ascii
317        }
318        
319        let gradient: Vec<char> = if self.light_mode {
320            self.gradient.chars().rev().collect()
321        } else {
322            self.gradient.chars().collect()
323        };
324        let iter = input
325            .pixels()
326            .map(|rgb| {
327                let Luma([luma]) = rgb.to_luma();
328                let luma = luma as f32 / 256.0;
329                let index = gradient.len() as f32 * luma;
330                let symbol = gradient[index as usize].stylize();
331                symbol
332            });
333        ascii.with_pass(iter, |_, symbol| symbol)
334    }
335}
336
337/// Settings used when computing edge symbols using edge detection. The default settings work well enough
338/// in most cases, and are exposed for advanced usage only. 
339#[derive(Clone, Copy)]
340pub struct Edges {
341    /// Angle symbols given in the order `| / — \`. 
342    pub angles: [char; 4], 
343    /// Blur size in pixels, used to reduce noise. 
344    pub sigma: f32,
345    /// How large a change in luma must be to register as an edge. Computed from (fallible) image analysis if
346    /// not given, which works best if the image has a clear background and foreground. Defined in [0, 1]. 
347    pub threshold: Option<f32>, 
348    /// The maximum fraction of opposing angles allowed when downsampling eges. E.g. if `|` is the most
349    /// common angle with population `p`, then the opposing angle `—` can have a population of no more than
350    /// `unanimity * p` for the edge to count. This serves to reduce noise. 
351    pub unanimity: f32, 
352    /// Sensitivity of the edge filtering performed to reduce noise. Defined in [0, ..]. 
353    pub sensitivity: f32, 
354}
355
356impl Default for Edges {
357    fn default() -> Self {
358        Edges {
359            angles: ['|', '/', '─', '\\'], 
360            sigma: 1.0, 
361            threshold: None, 
362            unanimity: 0.95, 
363            sensitivity: 1.0, 
364        }
365    }
366}
367
368impl Edges {
369    fn apply(&self, input: &RgbImage, ascii: AsciiImage, symbol_aspect_ratio: f32) -> AsciiImage {
370        let sigma_scale = u32::min(input.width(), input.height()) as f32 / 300.0;
371        let sigma = self.sigma * sigma_scale;
372
373        let edges = {
374            let input = imageops::grayscale(input);
375            let (weak_threshold, strong_threshold) = smart_thresholds(&input);
376
377            edge_detection::canny(
378                input,
379                sigma, 
380                strong_threshold, 
381                weak_threshold, 
382            )
383        };
384
385        let window_w = input.width() / ascii.width;
386        let window_h = input.height() / ascii.height;
387
388        // divide the image into windows and get the edge angles in each window
389        let windows = Itertools::cartesian_product(0..ascii.height, 0..ascii.width)
390            .map(|(y, x)| (y * window_h, x * window_w))
391            .map(|(y, x)| Itertools::cartesian_product(0..window_h, 0..window_w)
392                .map(move |(dy, dx)| (y + dy, x + dx))
393                .map(|(py, px)| edges.interpolate(px as f32, py as f32))
394            );
395
396        // downscale by computing a histogram for each window and choosing the most common angle kind among
397        // { | / — \ }. this preserves fine angle details compared to e.g. gaussian downsampling, which would
398        // average non-angles with angles, producing all non-angles
399        let window_angles = windows
400            .map(|window| window.map(|edge| (edge.magnitude() != 0.0)
401                .then(|| {
402                    let (dx, dy) = edge.dir_norm();
403                    f32::atan2(dy, symbol_aspect_ratio * dx)
404                })
405                .map(|angle| angle / std::f32::consts::PI * 0.5 + 0.5) // remap [-π, π] to [0, 1]
406                .map(|angle| (angle * 8.0).round() as u8) // quantise to 0..8
407            ));
408
409        let sum_threshold = match self.sensitivity {
410            0.0 => usize::MAX,
411            _ => {
412                let scale = u32::min(window_w, window_h);
413                usize::max(1, (scale as f32 / self.sensitivity) as usize)
414            }
415        };
416        let downsampled = window_angles
417            .map(|window| window
418                .flatten()
419                .map(|angle| angle % 4)
420            )
421            .map(histogram::<4>)
422            .map(|histogram| match histogram.iter().sum::<usize>() >= sum_threshold {
423                true => histogram.iter().position_max().and_then(|max_edge| {
424                    let max = histogram[max_edge] as f32;
425                    let opposite = histogram[(max_edge + 2) % 4] as f32;
426                    let unanimity = (max - opposite) / max;
427                    (unanimity >= self.unanimity).then_some(max_edge)
428                }),
429                false => None, 
430            });
431
432        // convert each angle kind to a symbol
433        let iter = downsampled.map(|angle| angle
434            .map(|angle| self.angles[angle])
435            .map(Stylize::stylize)
436        );
437        ascii.with_pass(iter, |prev, symbol| symbol.unwrap_or(prev))
438    }
439}
440
441/// Settings used when computing symbol colours. 
442#[derive(Clone, Copy, PartialEq)]
443pub enum Colours {
444    /// Render only hue and saturation, with the luma encoded by the gradient symbol. 
445    Chromatic, 
446    /// Render only luma. 
447    Greyscale, 
448    /// Render colours with RGB channels each quantised into the given number of buckets. 
449    Retro(u8), 
450    /// Render full colours. 
451    Full, 
452}
453
454impl Default for Colours {
455    fn default() -> Self {
456        Colours::Chromatic
457    }
458}
459
460impl Colours {
461    fn apply(&self, input: &RgbImage, ascii: AsciiImage) -> AsciiImage {
462        debug_assert!(input.width() == ascii.width && input.height() == ascii.height);
463
464        let iter = input
465            .pixels()
466            .map(|&rgb| match *self {
467                Colours::Chromatic => transform_hsv(&rgb, |hsv| {
468                    // models perceived saturation close to absolute black
469                    let saturation = f32::min(hsv.saturation, 2.0 * hsv.value);
470                    let value = 1.0;
471                    Hsv::new(hsv.hue, saturation, value)
472                }), 
473                Colours::Greyscale => {
474                    let image::Luma([luma]) = rgb.to_luma();
475                    image::Rgb([luma, luma, luma])
476                }, 
477                Colours::Retro(buckets) => {
478                    debug_assert!(buckets >= 1);
479
480                    let bucket_size = u8::MAX / buckets;
481                    let quantise = |x| bucket_size * (x / bucket_size);
482                    let image::Rgb(rgb) = rgb;
483                    image::Rgb(rgb.map(quantise))
484                }, 
485                Colours::Full => rgb, 
486            })
487            .map(|image::Rgb([r, g, b])| crossterm::style::Color::Rgb{ r, g, b });
488        ascii.with_pass(iter, StyledContent::with)
489    }
490}
491
492/// Computes a fixed-size histogram of a sequence of integers. 
493fn histogram<const N: usize>(data: impl IntoIterator<Item = u8>) -> [usize; N] {
494    data.into_iter().fold([0; N], |mut acc, x| {
495        acc[x as usize] += 1;
496        acc
497    })
498}
499
500/// Finds the best thresholds using Otsu's optimization-based thresholding algorithm. 
501fn smart_thresholds(image: &image::GrayImage) -> (f32, f32){
502    const VARIANCE_THRESHOLD: f32 = 4e8;
503
504    let histogram = histogram::<256>(image.pixels().map(|luma| luma[0]));
505    let histogram_iter = || histogram.iter()
506        .enumerate()
507        .map(|(luma, &bin_size)| (luma as f32 / 255.0, bin_size as f32));
508    let luma_sum = histogram_iter()
509        .map(|(luma, bin_size)| bin_size * luma)
510        .sum::<f32>();
511    let pixel_count = (image.width() * image.height()) as f32;
512    let fallback_thresholds = || {
513        let mean_luma = luma_sum / pixel_count;
514        let strong_threshold = mean_luma * 0.4;
515        let weak_threshold = strong_threshold * 0.2;
516        eprintln!("Using fallback thresholds");
517        (weak_threshold, strong_threshold)
518    };
519
520    let (min, max) = {
521        let non_zero = |&x| x > 0;
522        let min = histogram.iter().position(non_zero);
523        let max = histogram.iter().rposition(non_zero);
524
525        match Option::zip(min, max) {
526            Some((min, max)) => (min + 1, max - 1),
527            None => return fallback_thresholds(),
528        }
529    };
530    // TODO investigate if we can do a 2D search to optimize both the lower and upper threshold by maximizing
531    // the inter-class variance between 3 classes
532    histogram_iter()
533        // compute cumulative sum of pixel count and luma values
534        .scan((0.0, 0.0), |(pixel_sum, luma_sum), (luma, bin_size)| {
535            *pixel_sum += bin_size;
536            *luma_sum += bin_size * luma;
537            Some((*pixel_sum, *luma_sum, luma))
538        })
539        // ignore lumas outside the dynamic range of the image
540        .skip(min)
541        .take(max - min)
542        // compute the intra-class variance of the luma threshold
543        .map(|(pixel_cumsum, luma_cumsum, threshold)| {
544            let lo_pixel_cumsum = pixel_cumsum;
545            let hi_pixel_cumsum = pixel_count - lo_pixel_cumsum;
546            let lo_luma_cumsum = luma_cumsum;
547            let hi_luma_cumsum = luma_sum - lo_luma_cumsum;
548
549            let lo_mean = lo_luma_cumsum / lo_pixel_cumsum;
550            let hi_mean = hi_luma_cumsum / hi_pixel_cumsum;
551            let mean_diff = lo_mean - hi_mean;
552
553            let intra_class_variance = lo_pixel_cumsum * hi_pixel_cumsum * mean_diff * mean_diff;
554
555            (intra_class_variance, threshold)
556        })
557        .max_by(|x, y| PartialOrd::partial_cmp(x, y).unwrap())
558        .and_then(|(variance, threshold)| match variance >= VARIANCE_THRESHOLD {
559            true => Some((0.5 * threshold, threshold)),
560            false => None, 
561        })
562        .unwrap_or_else(fallback_thresholds)
563}
564
565/// Applies a function over the HSV representation of an RGB colour. 
566fn transform_hsv(rgb: &image::Rgb<u8>, function: impl Fn(palette::Hsv) -> palette::Hsv) -> image::Rgb<u8> {
567    // into HSV
568    let [r, g, b] = rgb.0.map(|x| x as f32 / (u8::MAX as f32));
569    let hsv = palette::Srgb::new(r, g, b).into_color();
570
571    // apply transformation
572    let palette::Hsv{ hue, saturation, value, .. } = function(hsv);
573    let hsv = palette::Hsv::new(hue, f32::clamp(saturation, 0.0, 1.0), f32::clamp(value, 0.0, 1.0));
574
575    // back into SRGB
576    let palette::Srgb{ red, green, blue, .. } = hsv.into_color();
577    let [r, g, b] = [red, green, blue].map(|x| (x * u8::MAX as f32) as u8);
578    image::Rgb([r, g, b])
579}