Let’s build… a retro text art generator!

Text art, often called “ASCII art”, is a way of displaying images in a text-only medium. You’ve probably seen it in the terminal output of some of your favorite command line apps.

For this project, we’ll be building a fully browser-based text art gene…


This content originally appeared on DEV Community and was authored by lionel-rowe

Text art, often called "ASCII art", is a way of displaying images in a text-only medium. You've probably seen it in the terminal output of some of your favorite command line apps.

For this project, we'll be building a fully browser-based text art generator, using React and TypeScript. The output will be highly customizable, with options for increasing brightness and contrast, width in characters, inverting the text and background colors, and even changing the character set we use to generate the images.

All the code is available on GitHub, and there's a live demo you can play around with too!

Demo

Here's what we'll be building

Algorithm

The basic algorithm is as follows:

  1. Calculate the relative density of each character in the character set (charset), averaged over all its pixels, when displayed in a monospace font. For example, . is very sparse, whereas # is very dense, and r is somewhere in between.

  2. Normalize the resulting absolute values into relative values in the range 0..1, where 0 is the sparsest character in the charset and 1 is the densest.

    If the "invert" option is selected, subtract the relative values from 1. This way, you get light pixels mapped to dense characters, suitable for light text on a dark background.

  3. Calculate the required aspect ratio (width:height) in "char-pixels", based on the rendered width and height of the characters, where each char-pixel is a character from the charset.

    For example, a charset composed of half-width characters will need to render more char-pixels vertically to have the same resulting aspect ratio as one composed of full-width characters.

  4. Render the target image in the required aspect ratio, then calculate the relative luminance of each pixel.

  5. Apply brightness and contrast modifying functions to each pixel value, based on the configured options.

  6. As before, normalize the absolute values into relative values in the range 0..1 (0 is the lightest and 1 is darkest).

  7. Map the resulting luminance value of each pixel onto the character closest in density value.

  8. Render the resulting 2d matrix of characters in a monospace font.

With the HTML5 Canvas API, we can do all this without leaving the browser! ?

Show me the code!

Without further ado...

Calculating character density

CanvasRenderingContext2D#getImageData gives a Uint8ClampedArray of channels in the order red, green, blue, alpha. For example, a 2×2 cyan image would result in the following data:

[
    // red  green  blue  alpha
       0,   255,   255,  0, // top-left pixel
       0,   255,   255,  0, // top-right pixel
       0,   255,   255,  0, // bottom-left pixel
       0,   255,   255,  0, // bottom-right pixel
]

As we're drawing black on transparent, we check which channel we're in using a modulo operation and ignore all the channels except for alpha (the transparency channel).

Here's our function for calculating character density:

export enum Channels {
    Red,
    Green,
    Blue,
    Alpha,

    Modulus,
}

export type Channel = Exclude<Channels, Channels.Modulus>

export const getRawCharDensity =
    (ctx: CanvasRenderingContext2D) =>
    (ch: string): CharVal => {
        const { canvas } = ctx

        canvas.height = 70
        canvas.width = 70

        const { width, height } = canvas

        const rect: Rect = [0, 0, width, height]

        ctx.font = '48px monospace'

        ctx.clearRect(...rect)

        ctx.fillStyle = '#000'
        ctx.fillText(ch, 10, 50)

        const val = ctx
            .getImageData(...rect)
            .data.reduce(
                (acc, cur, idx) =>
                    idx % Channels.Modulus === Channels.Alpha ? acc - cur : acc,
                0,
            )

        return {
            ch,
            val,
        }
    }

Next, we use this function to iterate over the whole charset, keeping a track of min and max:

export const getRawCharDensities = (charSet: CharSet): RawCharDensityData => {
    const canvas = document.createElement('canvas')

    const ctx = canvas.getContext('2d')!

    const charVals = [...charSet].map(getRawCharDensity(ctx))

    let max = -Infinity
    let min = Infinity

    for (const { val } of charVals) {
        max = Math.max(max, val)
        min = Math.min(min, val)
    }

    return {
        charVals,
        min,
        max,
    }
}

Finally, we normalize the values in relation to that min and max:

export const getNormalizedCharDensities =
    ({ invert }: CharValsOptions) =>
    ({ charVals, min, max }: RawCharDensityData) => {
        // minimum of 1, to prevent dividing by 0
        const range = max - min || 1

        return charVals
            .map(({ ch, val }) => {
                const v = (val - min) / range

                return {
                    ch,
                    val: invert ? 1 - v : v,
                }
            })
            .sort((a, b) => a.val - b.val)
    }

Calculating aspect ratio

Here's how we calculate aspect ratio:

// separators and newlines don't play well with the rendering logic
const SEPARATOR_REGEX = /[\n\p{Z}]/u

const REPEAT_COUNT = 100

const pre = appendInvisible('pre')

const _getCharScalingData =
    (repeatCount: number) =>
    (
        ch: string,
    ): {
        width: number
        height: number
        aspectRatio: AspectRatio
    } => {
        pre.textContent = `${`${ch.repeat(repeatCount)}\n`.repeat(repeatCount)}`

        const { width, height } = pre.getBoundingClientRect()

        const min = Math.min(width, height)

        pre.textContent = ''

        return {
            width: width / repeatCount,
            height: height / repeatCount,
            aspectRatio: [min / width, min / height],
        }
    }

For performance reasons, we assume all characters in the charset are equal width and height. If they're not, the output will be garbled anyway.

Calculating image pixel brightness

Here's how we calculate the relative brightness, or technically the relative perceived luminance, of each pixel:

const perceivedLuminance = {
    [Channels.Red]: 0.299,
    [Channels.Green]: 0.587,
    [Channels.Blue]: 0.114,
} as const

export const getMutableImageLuminanceValues = ({
    resolutionX,
    aspectRatio,
    img,
}: ImageLuminanceOptions) => {
    if (!img) {
        return {
            pixelMatrix: [],
            flatPixels: [],
        }
    }

    const { width, height } = img

    const scale = resolutionX / width

    const [w, h] = [width, height].map((x, i) =>
        Math.round(x * scale * aspectRatio[i]),
    )

    const rect: Rect = [0, 0, w, h]

    const canvas = document.createElement('canvas')

    canvas.width = w
    canvas.height = h

    const ctx = canvas.getContext('2d')!

    ctx.fillStyle = '#fff'

    ctx.fillRect(...rect)

    ctx.drawImage(img, ...rect)

    const pixelData = ctx.getImageData(...rect).data

    let curPix = 0

    const pixelMatrix: { val: number }[][] = []

    let max = -Infinity
    let min = Infinity

    for (const [idx, d] of pixelData.entries()) {
        const channel = (idx % Channels.Modulus) as Channel

        if (channel !== Channels.Alpha) {
            // rgb channel
            curPix += d * perceivedLuminance[channel]
        } else {
            // append pixel and reset during alpha channel

            // we set `ch` later, on second pass
            const thisPix = { val: curPix, ch: '' }

            max = Math.max(max, curPix)
            min = Math.min(min, curPix)

            if (idx % (w * Channels.Modulus) === Channels.Alpha) {
                // first pixel of line
                pixelMatrix.push([thisPix])
            } else {
                pixelMatrix[pixelMatrix.length - 1].push(thisPix)
            }

            curPix = 0
        }
    }

    // one-dimensional form, for ease of sorting and iterating.
    // changing individual pixels within this also
    // mutates `pixelMatrix`
    const flatPixels = pixelMatrix.flat()

    for (const pix of flatPixels) {
        pix.val = (pix.val - min) / (max - min)
    }

    // sorting allows us to iterate over the pixels
    // and charVals simultaneously, in linear time
    flatPixels.sort((a, b) => a.val - b.val)

    return {
        pixelMatrix,
        flatPixels,
    }
}

Why mutable, you ask? Well, we can improve performance by re-using this matrix for the characters to output.

In addition, we return a flattened and sorted version of the matrix. Mutating the objects in this flattened version persists through to the matrix itself. This allows for iterating in O(n) instead of O(nm) time complexity, where n is the number of pixels and m is the number of chars in the charset.

Map pixels to characters

Here's how we map the pixels onto characters:

export type CharPixelMatrixOptions = {
    charVals: CharVal[]
    brightness: number
    contrast: number
} & ImageLuminanceOptions

let cachedLuminanceInfo = {} as ImageLuminanceOptions &
    ReturnType<typeof getMutableImageLuminanceValues>

export const getCharPixelMatrix = ({
    brightness,
    contrast,
    charVals,
    ...imageLuminanceOptions
}: CharPixelMatrixOptions): CharPixelMatrix => {
    if (!charVals.length) return []

    const luminanceInfo = Object.entries(imageLuminanceOptions).every(
        ([key, val]) =>
            cachedLuminanceInfo[key as keyof typeof imageLuminanceOptions] ===
            val,
    )
        ? cachedLuminanceInfo
        : getMutableImageLuminanceValues(imageLuminanceOptions)

    cachedLuminanceInfo = { ...imageLuminanceOptions, ...luminanceInfo }

    const charPixelMatrix = luminanceInfo.pixelMatrix as CharVal[][]
    const allCharPixels = luminanceInfo.flatPixels as CharVal[]

    const multiplier = exponential(brightness)
    const polynomialFn = polynomial(exponential(contrast))

    let charValIdx = 0
    let charVal = charVals[charValIdx]

    for (const pix of allCharPixels) {
        while (charValIdx < charVals.length) {
            charVal = charVals[charValIdx]

            if (polynomialFn(pix.val) * multiplier <= charVal.val) {
                pix.ch = charVal.ch
                break
            } else {
                ++charValIdx
            }
        }

        // if none matched so far, we simply use the
        // last (lightest) character
        pix.ch = charVal?.ch || ' '
    }

    // cloning the array updates the reference to let React know it needs to re-render,
    // even though individual rows and cells are still the same mutated ones
    return [...charPixelMatrix]
}

The polynomial function increases contrast by skewing values toward the extremes. You can see some examples of polynomial functions at easings.netquad, cubic, quart, and quint are polynomials of degree 2, 3, 4, and 5 respectively.

The exponential function simply converts numbers in the range 0..100 (suitable for user-friendly configuration) into numbers exponentially increasing in the range 0.1..10 (giving better results for the visible output).

Here are those two functions:

export const polynomial = (degree: number) => (x: number) =>
    x < 0.5
        ? Math.pow(2, degree - 1) * Math.pow(x, degree)
        : 1 - Math.pow(-2 * x + 2, degree) / 2

export const exponential = (n: number) => 10 ** (n / 50 - 1)

...fin!

Finally, here's how we render the text art to a string:

export const getTextArt = (charPixelMatrix: CharPixelMatrix) =>
    charPixelMatrix.map((row) => row.map((x) => x.ch).join('')).join('\n')

The UI for this project is built in React ⚛ and mostly isn't as interesting as the algorithm itself. I might write a future post about that if there's interest in it.

I had a lot of fun and learned a lot creating this project! ? Future additional features, in approximate order of implementation difficulty, could include:

  • Allowing colorized output.
  • Moving at least some of the logic to web workers to prevent blocking of the main thread during expensive computation. Unfortunately, the OffscreenCanvas API is currently only available in Chromium-based browsers, which limits what we could do in this respect while remaining cross-browser compatible.
  • Adding an option to use dithering, which would improve results for small charsets or charsets with poor contrast characteristics.
  • Taking into account the sub-char-pixel properties of each character to give more accurate rendering. For example, _ is dense at the bottom and empty at the top, rather than uniformly low-density.
  • Adding an option to use an edge detection algorithm to improve results for certain types of images.
  • Allowing for variable-width charsets and fonts. This would require a massive rewrite of the algorithm and isn't something I've ever seen done before, but it would theoretically be possible.

I'm not planning on implementing any of these features in the near future, but those are some ideas to get you started for anyone that wants to try forking the project.

Thanks for reading! Don't forget to leave your feedback in the comments ?


This content originally appeared on DEV Community and was authored by lionel-rowe


Print Share Comment Cite Upload Translate Updates
APA

lionel-rowe | Sciencx (2021-07-14T16:13:57+00:00) Let’s build… a retro text art generator!. Retrieved from https://www.scien.cx/2021/07/14/lets-build-a-retro-text-art-generator/

MLA
" » Let’s build… a retro text art generator!." lionel-rowe | Sciencx - Wednesday July 14, 2021, https://www.scien.cx/2021/07/14/lets-build-a-retro-text-art-generator/
HARVARD
lionel-rowe | Sciencx Wednesday July 14, 2021 » Let’s build… a retro text art generator!., viewed ,<https://www.scien.cx/2021/07/14/lets-build-a-retro-text-art-generator/>
VANCOUVER
lionel-rowe | Sciencx - » Let’s build… a retro text art generator!. [Internet]. [Accessed ]. Available from: https://www.scien.cx/2021/07/14/lets-build-a-retro-text-art-generator/
CHICAGO
" » Let’s build… a retro text art generator!." lionel-rowe | Sciencx - Accessed . https://www.scien.cx/2021/07/14/lets-build-a-retro-text-art-generator/
IEEE
" » Let’s build… a retro text art generator!." lionel-rowe | Sciencx [Online]. Available: https://www.scien.cx/2021/07/14/lets-build-a-retro-text-art-generator/. [Accessed: ]
rf:citation
» Let’s build… a retro text art generator! | lionel-rowe | Sciencx | https://www.scien.cx/2021/07/14/lets-build-a-retro-text-art-generator/ |

Please log in to upload a file.




There are no updates yet.
Click the Upload button above to add an update.

You must be logged in to translate posts. Please log in or register.