ascii-silhouettify
Version:
A command-line app that converts images into ASCII silhouettes, a style of ASCII art distinguished by uniformly filled geometric shapes rather than lines or textures.
279 lines (272 loc) • 12.1 kB
JavaScript
import os from 'os';
import { parentPort } from 'worker_threads';
import 'sharp';
import 'chroma-js';
import 'path';
import 'url';
const RGBS = 'DAwMxQ8fE6EOwZwAADfaiBeYOpbdzMzMdnZ250hWFsYM+fGlO3j/tACeYdbW8vLyAAAAAABfAACHAACvAADXAAD/AF8AAF9fAF+HAF+' +
'vAF/XAF//AIcAAIdfAIeHAIevAIfXAIf/AK8AAK9fAK+HAK+vAK/XAK//ANcAANdfANeHANevANfXANf/AP8AAP9fAP+HAP+vAP/XAP//XwAAXwB' +
'fXwCHXwCvXwDXXwD/X18AX19fX1+HX1+vX1/XX1//X4cAX4dfX4eHX4evX4fXX4f/X68AX69fX6+HX6+vX6/XX6//X9cAX9dfX9eHX9evX9fXX9f' +
'/X/8AX/9fX/+HX/+vX//XX///hwAAhwBfhwCHhwCvhwDXhwD/h18Ah19fh1+Hh1+vh1/Xh1//h4cAh4dfh4eHh4evh4fXh4f/h68Ah69fh6+Hh6+' +
'vh6/Xh6//h9cAh9dfh9eHh9evh9fXh9f/h/8Ah/9fh/+Hh/+vh//Xh///rwAArwBfrwCHrwCvrwDXrwD/r18Ar19fr1+Hr1+vr1/Xr1//r4cAr4d' +
'fr4eHr4evr4fXr4f/r68Ar69fr6+Hr6+vr6/Xr6//r9cAr9dfr9eHr9evr9fXr9f/r/8Ar/9fr/+Hr/+vr//Xr///1wAA1wBf1wCH1wCv1wDX1wD' +
'/118A119f11+H11+v11/X11//14cA14df14eH14ev14fX14f/168A169f16+H16+v16/X16//19cA19df19eH19ev19fX19f/1/8A1/9f1/+H1/+' +
'v1//X1////wAA/wBf/wCH/wCv/wDX/wD//18A/19f/1+H/1+v/1/X/1///4cA/4df/4eH/4ev/4fX/4f//68A/69f/6+H/6+v/6/X/6///9cA/9d' +
'f/9eH/9ev/9fX/9f///8A//9f//+H//+v///X////CAgIEhISHBwcJiYmMDAwOjo6RERETk5OWFhYYmJibGxsdnZ2gICAioqKlJSUnp6eqKiosrK' +
'yvLy8xsbG0NDQ2tra5OTk7u7u';
Buffer.from(RGBS, 'base64');
var Palette;
(function (Palette) {
Palette[Palette["STANDARD_8"] = 0] = "STANDARD_8";
Palette[Palette["STANDARD_16"] = 1] = "STANDARD_16";
Palette[Palette["EXTENDED_240"] = 2] = "EXTENDED_240";
Palette[Palette["EXTENDED_256"] = 3] = "EXTENDED_256";
})(Palette || (Palette = {}));
new Array(256);
function getIndex(image, x, y) {
const X = Math.round(x);
const Y = Math.round(y);
if (X < 0 || Y < 0 || X >= image.width || Y >= image.height) {
return 0;
}
return image.indices[image.width * Y + X];
}
class Ascii {
text;
matched;
constructor(text, matched) {
this.text = text;
this.matched = matched;
}
}
var Format;
(function (Format) {
Format[Format["TEXT"] = 0] = "TEXT";
Format[Format["HTML"] = 1] = "HTML";
Format[Format["NEOFETCH"] = 2] = "NEOFETCH";
})(Format || (Format = {}));
const TERM_WIDTH = 9;
const TERM_HEIGHT = 19;
function toMonochromeAscii(task, originX, originY) {
const { image, rows, rowScale, cols, colScale, glyphScaleX, glyphScaleY, glyphInfo, format } = task;
const { masks: glyphMasks, glyphs } = glyphInfo;
let text = '';
let matched = 0;
const region = new Array(3);
for (let r = 0; r < rows; ++r) {
const glyphOriginY = originY + rowScale * r;
for (let c = 0; c < cols; ++c) {
const glyphOriginX = originX + colScale * c;
region[2] = 0x7FFFFFFF;
region[1] = region[0] = 0xFFFFFFFF;
// Attempt to substitute the region with a glyph starting with the character containing the most pixels down
// to the space character. If any of the glyph's pixels coincide with a black pixel in image region, then
// that character is excluded.
for (let y = 0; y < TERM_HEIGHT; ++y) {
const glyphY = glyphOriginY + glyphScaleY * y;
const tableOffset = TERM_WIDTH * y;
for (let x = 0; x < TERM_WIDTH; ++x) {
const glyphX = glyphOriginX + glyphScaleX * x;
if (getIndex(image, glyphX, glyphY) === 0) {
const row = glyphMasks[tableOffset + x];
region[2] &= row[2];
region[1] &= row[1];
region[0] &= row[0];
}
}
}
let glyphIndex;
if (region[2] !== 0) {
glyphIndex = 95 - Math.clz32(region[2]);
}
else if (region[1] !== 0) {
glyphIndex = 63 - Math.clz32(region[1]);
}
else {
glyphIndex = 31 - Math.clz32(region[0]);
}
// Append the printable ASCII character.
switch (format) {
case Format.HTML:
text += glyphs[glyphIndex].htmlEscapedCharacter;
break;
case Format.NEOFETCH:
text += glyphs[glyphIndex].neofetchEscapedCharacter;
break;
default:
text += glyphs[glyphIndex].character;
break;
}
// Tally the number of glyph pixels that align with image pixels.
matched += glyphs[glyphIndex].count;
}
text += os.EOL;
}
return new Ascii(text, matched);
}
function toColorAscii(task, originX, originY) {
const { image, rows, rowScale, cols, colScale, glyphScaleX, glyphScaleY, glyphInfo, format, palette, htmlColors } = task;
const { masks: glyphMasks, glyphs, minCount: glyphMinCount } = glyphInfo;
const region = new Array(3);
const colorIndexCounts = new Map();
const ansi16 = palette === Palette.STANDARD_8 || palette === Palette.STANDARD_16;
let text = '';
let notFirstSpan = false;
let lastColorIndex = -1;
let matched = 0;
let neofetchColors = 0;
for (let r = 0; r < rows; ++r) {
const glyphOriginY = originY + rowScale * r;
for (let c = 0; c < cols; ++c) {
const glyphOriginX = originX + colScale * c;
// Count the number of times each color index appears in the rectangular region to be replaced by a glyph.
colorIndexCounts.clear();
for (let y = 0; y < TERM_HEIGHT; ++y) {
const glyphY = glyphOriginY + glyphScaleY * y;
for (let x = 0; x < TERM_WIDTH; ++x) {
const glyphX = glyphOriginX + glyphScaleX * x;
const colorIndex = getIndex(image, glyphX, glyphY);
// Do not count very dark colors.
if (colorIndex !== 0) {
const count = colorIndexCounts.get(colorIndex);
colorIndexCounts.set(colorIndex, (count === undefined) ? 1 : count + 1);
}
}
}
// Remove counts less-than the minimum number of pixels in a printable ASCII character that is not a space.
colorIndexCounts.forEach((count, colorIndex) => {
if (count < glyphMinCount) {
colorIndexCounts.delete(colorIndex);
}
});
// If there are no counts, then the region is very dark. Replace with a space character in that case.
if (colorIndexCounts.size === 0) {
text += ' ';
continue;
}
// For each of the remaining counted color indices, attempt to substitute the region with a colored glyph
// starting with the character containing the most pixels down to the space character. If any of the
// glyph's pixels do not coincide with an image pixel of a specified color index, then that character is
// excluded.
let bestGlyphIndex = 0;
let bestColorIndex = 0;
colorIndexCounts.forEach((_, colorIndex) => {
region[2] = 0x7FFFFFFF;
region[1] = region[0] = 0xFFFFFFFF;
for (let y = 0; y < TERM_HEIGHT; ++y) {
const glyphY = glyphOriginY + glyphScaleY * y;
const tableOffset = TERM_WIDTH * y;
for (let x = 0; x < TERM_WIDTH; ++x) {
const glyphX = glyphOriginX + glyphScaleX * x;
if (getIndex(image, glyphX, glyphY) !== colorIndex) {
const row = glyphMasks[tableOffset + x];
region[2] &= row[2];
region[1] &= row[1];
region[0] &= row[0];
}
}
}
let glyphIndex;
if (region[2] !== 0) {
glyphIndex = 95 - Math.clz32(region[2]);
}
else if (region[1] !== 0) {
glyphIndex = 63 - Math.clz32(region[1]);
}
else {
glyphIndex = 31 - Math.clz32(region[0]);
}
// Accept the best character and color combination.
if (glyphs[glyphIndex].count > glyphs[bestGlyphIndex].count) {
bestGlyphIndex = glyphIndex;
bestColorIndex = colorIndex;
}
});
// If no best is found, then no non-space character of any color aligns with all the pixels in the region.
// A space is the only acceptable character in that case.
if (bestGlyphIndex === 0) {
text += ' ';
continue;
}
// If the color is different from the previous one, then append the ANSI escape code to set the foreground
// color to an index of the 256-color palette.
if (lastColorIndex !== bestColorIndex) {
switch (format) {
case Format.HTML:
if (notFirstSpan) {
text += "</span>";
}
text += `<span style="color: #${htmlColors[bestColorIndex]};">`;
notFirstSpan = true;
break;
case Format.NEOFETCH: {
const style = image.neofetchStyles[bestColorIndex];
neofetchColors = Math.max(neofetchColors, style.charCodeAt(3) - 48);
text += style;
break;
}
default:
if (ansi16) {
if (bestColorIndex < 8) {
text += `\x1b[3${bestColorIndex}m`;
}
else {
text += `\x1b[1;3${bestColorIndex - 8}m`;
}
}
else {
text += `\x1b[38;5;${bestColorIndex}m`;
}
break;
}
lastColorIndex = bestColorIndex;
}
// Append the printable ASCII character.
switch (format) {
case Format.HTML:
text += glyphs[bestGlyphIndex].htmlEscapedCharacter;
break;
case Format.NEOFETCH:
text += glyphs[bestGlyphIndex].neofetchEscapedCharacter;
break;
default:
text += glyphs[bestGlyphIndex].character;
break;
}
// Tally the number of glyph pixels that align with image pixels.
matched += glyphs[bestGlyphIndex].count;
}
text += os.EOL;
}
switch (format) {
case Format.HTML:
if (notFirstSpan) {
text += '</span>';
}
break;
case Format.NEOFETCH: {
let header = 'colors';
for (let i = 0; i < neofetchColors; ++i) {
header += ` ${image.neofetchIndices[i]}`;
}
text = header + os.EOL + os.EOL + text;
break;
}
default:
// Append ANSI escape code to reset the text formatting to the terminal's default settings.
text += '\x1b[0m';
break;
}
return new Ascii(text, matched);
}
parentPort.on('message', (task) => {
const func = task.color ? toColorAscii : toMonochromeAscii;
let ascii = new Ascii('', 0);
task.offsets.forEach(offset => {
const result = func(task, offset.x + task.marginX, offset.y + task.marginY);
if (result.matched > ascii.matched) {
ascii = result;
}
});
parentPort.postMessage(ascii);
});