Your Web News in One Place

Help Webnuz

Referal links:

Sign up for GreenGeeks web hosting
July 14, 2021 04:13 pm GMT

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 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 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 22 cyan image would result in the following data:

[    // red  green  blue  alpha       0,   255,   255,  255, // top-left pixel       0,   255,   255,  255, // top-right pixel       0,   255,   255,  255, // bottom-left pixel       0,   255,   255,  255, // 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 logicconst SEPARATOR_REGEX = /[
\p
{Z}]/uconst REPEAT_COUNT = 100const pre = appendInvisible('pre')const _getCharScalingData = (repeatCount: number) => ( ch: string, ): { width: number height: number aspectRatio: AspectRatio } => { pre.textContent = `${`${ch.repeat(repeatCount)}
`
.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 constexport 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} & ImageLuminanceOptionslet 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.net quad, 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) / 2export 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('
')

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


Original Link: https://dev.to/lionelrowe/let-s-build-a-retro-text-art-generator-3og2

Share this article:    Share on Facebook
View Full Article

Dev To

An online community for sharing and discovering great ideas, having debates, and making friends

More About this Source Visit Dev To