33
loading...
This website collects cookies to deliver better user experience
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 a
is somewhere in between.
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.
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.
Render the target image in the required aspect ratio, then calculate the relative luminance of each pixel.
Apply brightness and contrast modifying functions to each pixel value, based on the configured options.
As before, normalize the absolute values into relative values in the range 0..1
(0 is the darkest and 1 is lightest).
Map the resulting luminance value of each pixel onto the character closest in density value.
Render the resulting 2d matrix of characters in a monospace font.
[
// red green blue alpha
255, 0, 0, 255, // top-left pixel
0, 255, 0, 255, // top-right pixel
0, 0, 255, 255, // bottom-left pixel
0, 0, 0, 0, // bottom-right pixel
]
alpha
(the transparency channel).const CANVAS_SIZE = 70
const FONT_SIZE = 50
const BORDER = (CANVAS_SIZE - FONT_SIZE) / 2
const LEFT = BORDER
const BASELINE = CANVAS_SIZE - BORDER
const RECT: Rect = [0, 0, CANVAS_SIZE, CANVAS_SIZE]
export enum Channels {
Red,
Green,
Blue,
Alpha,
Modulus,
}
export type Channel = Exclude<Channels, Channels.Modulus>
export const getRawCharDensity =
(ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D) =>
(ch: string): CharVal => {
ctx.clearRect(...RECT)
ctx.fillText(ch, LEFT, BASELINE)
const val = ctx
.getImageData(...RECT)
.data.reduce(
(total, val, idx) =>
idx % Channels.Modulus === Channels.Alpha
? total - val
: total,
0,
)
return {
ch,
val,
}
}
min
and max
:export const createCanvas = (width: number, height: number) =>
globalThis.OffscreenCanvas
? new OffscreenCanvas(width, height)
: (Object.assign(document.createElement('canvas'), {
width,
height,
}) as HTMLCanvasElement)
export const getRawCharDensities = (charSet: CharSet): RawCharDensityData => {
const canvas = createCanvas(CANVAS_SIZE, CANVAS_SIZE)
const ctx = canvas.getContext('2d')!
ctx.font = `${FONT_SIZE}px monospace`
ctx.fillStyle = '#000'
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,
}
}
min
and max
:export const getNormalizedCharDensities =
({ invert }: CharValsOptions) =>
({ charVals, min, max }: RawCharDensityData) => {
// minimum of 1, to prevent dividing by 0
const range = Math.max(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)
}
// 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],
}
}
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 = createCanvas(w, 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,
}
}
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.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 flatCharPixels = luminanceInfo.flatPixels as CharVal[]
const multiplier = exponential(brightness)
const polynomialFn = polynomial(exponential(contrast))
let charValIdx = 0
let charVal: CharVal
for (const charPix of flatCharPixels) {
while (charValIdx < charVals.length) {
charVal = charVals[charValIdx]
if (polynomialFn(charPix.val) * multiplier > charVal.val) {
++charValIdx
continue
} else {
break
}
}
charPix.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]
}
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.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).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) => Math.pow(10, n / 50 - 1)
export const getTextArt = (charPixelMatrix: CharPixelMatrix) =>
charPixelMatrix.map((row) => row.map((x) => x.ch).join('')).join('\n')
_
is dense at the bottom and empty at the top, rather than uniformly low-density.