547 lines
16 KiB
TypeScript
547 lines
16 KiB
TypeScript
export interface AsciiOptions {
|
|
width?: number;
|
|
height?: number;
|
|
contrast?: number;
|
|
exposure?: number;
|
|
invert?: boolean;
|
|
saturation?: number;
|
|
gamma?: number;
|
|
charSet?: CharSetKey | string;
|
|
color?: boolean;
|
|
dither?: boolean;
|
|
enhanceEdges?: boolean;
|
|
autoStretch?: boolean;
|
|
overlayStrength?: number;
|
|
aspectMode?: 'fit' | 'fill' | 'stretch';
|
|
denoise?: boolean;
|
|
fontAspectRatio?: number;
|
|
onProgress?: (progress: number) => void;
|
|
}
|
|
|
|
export interface AsciiResult {
|
|
output: string;
|
|
isHtml: boolean;
|
|
width: number;
|
|
height: number;
|
|
}
|
|
|
|
export type CharSetKey = 'standard' | 'simple' | 'blocks' | 'minimal' | 'matrix' | 'dots' | 'ascii_extended';
|
|
export type AspectMode = 'fit' | 'fill' | 'stretch';
|
|
|
|
export const CHAR_SETS: Record<CharSetKey, string> = {
|
|
standard: '@W%$NQ08GBR&ODHKUgSMw#Xbdp5q9C26APahk3EFVesm{}o4JZcjnuy[f1xi*7zYt(l/I\\v)T?]r><+^"L;|!~:,-_.\' ',
|
|
simple: '@%#*+=-:. ',
|
|
blocks: '█▓▒░ ',
|
|
minimal: '#+-. ',
|
|
matrix: 'ハミヒーウシナモニサワツオリアホテマケメエカキムユラセネスタヌヘ1234567890:.=*+-<>',
|
|
dots: '⣿⣷⣯⣟⡿⢿⣻⣽⣾⣶⣦⣤⣄⣀⡀ ',
|
|
ascii_extended: '░▒▓█▀▄▌▐│┤╡╢╖╕╣║╗╝╜╛┐└┴┬├─┼╞╟╚╔╩╦╠═╬╧╨╤╥╙╘╒╓╫╪┘┌ '
|
|
};
|
|
|
|
export const ASPECT_MODES: Record<string, AspectMode> = {
|
|
fit: 'fit',
|
|
fill: 'fill',
|
|
stretch: 'stretch'
|
|
};
|
|
|
|
interface ImageMetadata {
|
|
color_dominant?: [number, number, number];
|
|
color_palette?: [number, number, number][];
|
|
has_fine_detail?: boolean;
|
|
}
|
|
|
|
export class AsciiGenerator {
|
|
private ctx: CanvasRenderingContext2D | null = null;
|
|
private canvas: HTMLCanvasElement | null = null;
|
|
private sharpCanvas: HTMLCanvasElement | null = null;
|
|
private sharpCtx: CanvasRenderingContext2D | null = null;
|
|
private denoiseCanvas: HTMLCanvasElement | null = null;
|
|
private denoiseCtx: CanvasRenderingContext2D | null = null;
|
|
private colorData: Uint8Array | null = null;
|
|
|
|
dispose(): void {
|
|
this.ctx = null;
|
|
this.sharpCtx = null;
|
|
this.denoiseCtx = null;
|
|
this.colorData = null;
|
|
|
|
if (this.canvas) {
|
|
this.canvas.width = 0;
|
|
this.canvas.height = 0;
|
|
this.canvas = null;
|
|
}
|
|
if (this.sharpCanvas) {
|
|
this.sharpCanvas.width = 0;
|
|
this.sharpCanvas.height = 0;
|
|
this.sharpCanvas = null;
|
|
}
|
|
if (this.denoiseCanvas) {
|
|
this.denoiseCanvas.width = 0;
|
|
this.denoiseCanvas.height = 0;
|
|
this.denoiseCanvas = null;
|
|
}
|
|
}
|
|
|
|
async generate(imageSource: string | HTMLImageElement, options: AsciiOptions = {}): Promise<string | AsciiResult> {
|
|
if (typeof document === 'undefined') {
|
|
throw new Error('AsciiGenerator requires a browser environment.');
|
|
}
|
|
|
|
const onProgress = options.onProgress ?? (() => { });
|
|
onProgress(0);
|
|
|
|
const img = await this.resolveImage(imageSource);
|
|
onProgress(10);
|
|
|
|
const requestedWidth = options.width ?? 100;
|
|
const fontAspectRatio = options.fontAspectRatio ?? 0.55;
|
|
const imgRatio = this.getImageRatio(img);
|
|
const aspectMode = options.aspectMode ?? 'fit';
|
|
|
|
let width: number, height: number;
|
|
if (aspectMode === 'stretch') {
|
|
width = requestedWidth;
|
|
height = options.height ?? Math.floor(requestedWidth / 2);
|
|
} else if (aspectMode === 'fill') {
|
|
width = requestedWidth;
|
|
const naturalHeight = Math.floor(requestedWidth / (imgRatio / fontAspectRatio));
|
|
height = options.height ?? naturalHeight;
|
|
} else {
|
|
width = requestedWidth;
|
|
height = options.height ?? Math.floor(requestedWidth / (imgRatio / fontAspectRatio));
|
|
}
|
|
|
|
let charSet: string = options.charSet ?? 'standard';
|
|
if (charSet in CHAR_SETS) {
|
|
charSet = CHAR_SETS[charSet as CharSetKey];
|
|
}
|
|
|
|
if (!this.canvas) {
|
|
this.canvas = document.createElement('canvas');
|
|
}
|
|
this.canvas.width = width;
|
|
this.canvas.height = height;
|
|
this.ctx = this.canvas.getContext('2d');
|
|
|
|
if (!this.sharpCanvas) {
|
|
this.sharpCanvas = document.createElement('canvas');
|
|
}
|
|
this.sharpCanvas.width = width;
|
|
this.sharpCanvas.height = height;
|
|
this.sharpCtx = this.sharpCanvas.getContext('2d');
|
|
|
|
const exposure = options.exposure ?? 1.0;
|
|
const contrast = options.contrast ?? 1.0;
|
|
const saturation = options.saturation ?? 1.2;
|
|
const gamma = options.gamma ?? 1.0;
|
|
const dither = options.dither ?? false;
|
|
const enhanceEdges = options.enhanceEdges ?? false;
|
|
const autoStretch = options.autoStretch !== false;
|
|
const overlayStrength = options.overlayStrength ?? 0.3;
|
|
const denoise = options.denoise ?? false;
|
|
const colorOutput = options.color ?? false;
|
|
|
|
onProgress(20);
|
|
|
|
let sourceImage: HTMLImageElement | HTMLCanvasElement = img;
|
|
if (denoise) {
|
|
if (!this.denoiseCanvas) {
|
|
this.denoiseCanvas = document.createElement('canvas');
|
|
}
|
|
this.denoiseCanvas.width = width;
|
|
this.denoiseCanvas.height = height;
|
|
this.denoiseCtx = this.denoiseCanvas.getContext('2d');
|
|
if (this.denoiseCtx) {
|
|
this.denoiseCtx.filter = 'blur(0.5px)';
|
|
this.denoiseCtx.drawImage(img, 0, 0, width, height);
|
|
sourceImage = this.denoiseCanvas;
|
|
}
|
|
}
|
|
|
|
let sx = 0, sy = 0, sw = img.width, sh = img.height;
|
|
if (aspectMode === 'fill' && options.height) {
|
|
const targetRatio = width / (options.height * fontAspectRatio);
|
|
if (imgRatio > targetRatio) {
|
|
sw = img.height * targetRatio;
|
|
sx = (img.width - sw) / 2;
|
|
} else {
|
|
sh = img.width / targetRatio;
|
|
sy = (img.height - sh) / 2;
|
|
}
|
|
}
|
|
|
|
if (this.sharpCtx) {
|
|
this.sharpCtx.filter = `brightness(${exposure}) contrast(${contrast}) saturate(${saturation})`;
|
|
if (denoise && sourceImage === this.denoiseCanvas) {
|
|
this.sharpCtx.drawImage(sourceImage, 0, 0, width, height);
|
|
} else {
|
|
this.sharpCtx.drawImage(img, sx, sy, sw, sh, 0, 0, width, height);
|
|
}
|
|
}
|
|
|
|
if (enhanceEdges && this.sharpCtx) {
|
|
this.sharpCtx.filter = 'none';
|
|
this.sharpCtx.globalCompositeOperation = 'source-over';
|
|
const edgeCanvas = document.createElement('canvas');
|
|
edgeCanvas.width = width;
|
|
edgeCanvas.height = height;
|
|
const edgeCtx = edgeCanvas.getContext('2d');
|
|
if (edgeCtx) {
|
|
edgeCtx.filter = 'contrast(2) brightness(0.8)';
|
|
edgeCtx.drawImage(this.sharpCanvas!, 0, 0);
|
|
this.sharpCtx.globalAlpha = 0.4;
|
|
this.sharpCtx.globalCompositeOperation = 'multiply';
|
|
this.sharpCtx.drawImage(edgeCanvas, 0, 0);
|
|
this.sharpCtx.globalCompositeOperation = 'source-over';
|
|
this.sharpCtx.globalAlpha = 1.0;
|
|
}
|
|
}
|
|
|
|
onProgress(40);
|
|
|
|
if (this.ctx && this.sharpCanvas) {
|
|
this.ctx.globalAlpha = 1.0;
|
|
this.ctx.drawImage(this.sharpCanvas, 0, 0);
|
|
if (overlayStrength > 0) {
|
|
this.ctx.globalCompositeOperation = 'overlay';
|
|
this.ctx.globalAlpha = overlayStrength;
|
|
this.ctx.drawImage(this.sharpCanvas, 0, 0);
|
|
this.ctx.globalCompositeOperation = 'source-over';
|
|
this.ctx.globalAlpha = 1.0;
|
|
}
|
|
}
|
|
|
|
const imageData = this.ctx!.getImageData(0, 0, width, height);
|
|
const pixels = imageData.data;
|
|
|
|
onProgress(50);
|
|
|
|
const lumMatrix = new Float32Array(width * height);
|
|
let minLum = 1.0, maxLum = 0.0;
|
|
|
|
if (colorOutput) {
|
|
this.colorData = new Uint8Array(width * height * 3);
|
|
}
|
|
|
|
for (let i = 0; i < width * height; i++) {
|
|
const offset = i * 4;
|
|
const r = pixels[offset];
|
|
const g = pixels[offset + 1];
|
|
const b = pixels[offset + 2];
|
|
let lum = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
|
|
|
|
if (colorOutput && this.colorData) {
|
|
this.colorData[i * 3] = r;
|
|
this.colorData[i * 3 + 1] = g;
|
|
this.colorData[i * 3 + 2] = b;
|
|
}
|
|
|
|
if (gamma !== 1.0) {
|
|
lum = Math.pow(lum, gamma);
|
|
}
|
|
|
|
if (options.invert) {
|
|
lum = 1 - lum;
|
|
}
|
|
|
|
lumMatrix[i] = lum;
|
|
if (lum < minLum) minLum = lum;
|
|
if (lum > maxLum) maxLum = lum;
|
|
}
|
|
|
|
onProgress(60);
|
|
|
|
const lumRange = maxLum - minLum;
|
|
if (autoStretch && lumRange > 0.01) {
|
|
for (let i = 0; i < lumMatrix.length; i++) {
|
|
lumMatrix[i] = (lumMatrix[i] - minLum) / lumRange;
|
|
}
|
|
}
|
|
|
|
if (dither) {
|
|
const levels = charSet.length;
|
|
for (let y = 0; y < height; y++) {
|
|
for (let x = 0; x < width; x++) {
|
|
const i = y * width + x;
|
|
const oldVal = lumMatrix[i];
|
|
const newVal = Math.round(oldVal * (levels - 1)) / (levels - 1);
|
|
lumMatrix[i] = newVal;
|
|
const error = oldVal - newVal;
|
|
|
|
if (x + 1 < width) lumMatrix[i + 1] += error * 7 / 16;
|
|
if (y + 1 < height) {
|
|
if (x > 0) lumMatrix[(y + 1) * width + (x - 1)] += error * 3 / 16;
|
|
lumMatrix[(y + 1) * width + x] += error * 5 / 16;
|
|
if (x + 1 < width) lumMatrix[(y + 1) * width + (x + 1)] += error * 1 / 16;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
onProgress(80);
|
|
|
|
let output = '';
|
|
|
|
if (colorOutput && this.colorData) {
|
|
for (let y = 0; y < height; y++) {
|
|
for (let x = 0; x < width; x++) {
|
|
const i = y * width + x;
|
|
const brightness = Math.max(0, Math.min(1, lumMatrix[i]));
|
|
const charIndex = Math.floor(brightness * (charSet.length - 1));
|
|
const safeIndex = Math.max(0, Math.min(charSet.length - 1, charIndex));
|
|
const char = charSet[safeIndex];
|
|
|
|
const r = this.colorData[i * 3];
|
|
const g = this.colorData[i * 3 + 1];
|
|
const b = this.colorData[i * 3 + 2];
|
|
|
|
const safeChar = char === '<' ? '<' : char === '>' ? '>' : char === '&' ? '&' : char;
|
|
output += `<span style="color:rgb(${r},${g},${b})">${safeChar}</span>`;
|
|
}
|
|
output += '\n';
|
|
}
|
|
} else {
|
|
for (let y = 0; y < height; y++) {
|
|
for (let x = 0; x < width; x++) {
|
|
const brightness = Math.max(0, Math.min(1, lumMatrix[y * width + x]));
|
|
const charIndex = Math.floor(brightness * (charSet.length - 1));
|
|
const safeIndex = Math.max(0, Math.min(charSet.length - 1, charIndex));
|
|
output += charSet[safeIndex];
|
|
}
|
|
output += '\n';
|
|
}
|
|
}
|
|
|
|
onProgress(100);
|
|
|
|
if (colorOutput) {
|
|
return {
|
|
output,
|
|
isHtml: true,
|
|
width,
|
|
height
|
|
};
|
|
}
|
|
|
|
return output;
|
|
}
|
|
|
|
private getImageRatio(img: HTMLImageElement): number {
|
|
if (img.width && img.height) {
|
|
return img.width / img.height;
|
|
}
|
|
return 1;
|
|
}
|
|
|
|
private resolveImage(src: string | HTMLImageElement): Promise<HTMLImageElement> {
|
|
return new Promise((resolve, reject) => {
|
|
if (src instanceof HTMLImageElement) {
|
|
if (src.complete) return resolve(src);
|
|
src.onload = () => resolve(src);
|
|
src.onerror = reject;
|
|
return;
|
|
}
|
|
const img = new Image();
|
|
img.crossOrigin = 'Anonymous';
|
|
img.src = src;
|
|
img.onload = () => resolve(img);
|
|
img.onerror = () => reject(new Error('Failed to load image'));
|
|
});
|
|
}
|
|
}
|
|
|
|
export async function imageToAscii(imageSource: string | HTMLImageElement, options: AsciiOptions = {}): Promise<string | AsciiResult> {
|
|
const generator = new AsciiGenerator();
|
|
return generator.generate(imageSource, options);
|
|
}
|
|
|
|
export function autoTuneImage(img: HTMLImageElement, meta: ImageMetadata | null = null): Partial<AsciiOptions> {
|
|
if (typeof document === 'undefined') return {};
|
|
|
|
const canvas = document.createElement('canvas');
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) return {};
|
|
|
|
const size = 100;
|
|
canvas.width = size;
|
|
canvas.height = size;
|
|
ctx.drawImage(img, 0, 0, size, size);
|
|
|
|
const imageData = ctx.getImageData(0, 0, size, size);
|
|
const pixels = imageData.data;
|
|
|
|
const histogram = new Array(256).fill(0);
|
|
let totalLum = 0;
|
|
|
|
for (let i = 0; i < pixels.length; i += 4) {
|
|
const lum = Math.round(0.2126 * pixels[i] + 0.7152 * pixels[i + 1] + 0.0722 * pixels[i + 2]);
|
|
histogram[lum]++;
|
|
totalLum += lum;
|
|
}
|
|
|
|
const pixelCount = pixels.length / 4;
|
|
const avgLum = totalLum / pixelCount;
|
|
|
|
let p5: number | null = null, p95 = 255, count = 0;
|
|
for (let i = 0; i < 256; i++) {
|
|
count += histogram[i];
|
|
if (p5 === null && count > pixelCount * 0.05) p5 = i;
|
|
if (count > pixelCount * 0.95) { p95 = i; break; }
|
|
}
|
|
p5 = p5 ?? 0;
|
|
|
|
const midPoint = (p5 + p95) / 2;
|
|
let exposure = 128 / Math.max(midPoint, 10);
|
|
exposure = Math.max(0.4, Math.min(2.8, exposure));
|
|
|
|
const activeRange = p95 - p5;
|
|
let contrast = 1.1;
|
|
if (activeRange < 50) contrast = 2.5;
|
|
else if (activeRange < 100) contrast = 1.8;
|
|
else if (activeRange < 150) contrast = 1.4;
|
|
|
|
let invert = false;
|
|
let saturation = 1.2;
|
|
let useEdgeDetection = true;
|
|
|
|
if (meta) {
|
|
const { color_dominant, color_palette } = meta;
|
|
|
|
if (color_dominant) {
|
|
const [r, g, b] = color_dominant;
|
|
const domLum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
if (domLum > 140) {
|
|
invert = true;
|
|
useEdgeDetection = false;
|
|
}
|
|
}
|
|
|
|
if (color_palette && Array.isArray(color_palette) && color_palette.length > 0) {
|
|
let totalSat = 0;
|
|
for (const [r, g, b] of color_palette) {
|
|
const max = Math.max(r, g, b);
|
|
const delta = max - Math.min(r, g, b);
|
|
const s = max === 0 ? 0 : delta / max;
|
|
totalSat += s;
|
|
}
|
|
const avgSat = totalSat / color_palette.length;
|
|
|
|
if (avgSat > 0.4) saturation = 1.6;
|
|
else if (avgSat < 0.1) saturation = 0.0;
|
|
else saturation = 1.2;
|
|
}
|
|
}
|
|
|
|
if (useEdgeDetection) {
|
|
let edgeLumSum = 0;
|
|
let edgeCount = 0;
|
|
for (let y = 0; y < size; y++) {
|
|
for (let x = 0; x < size; x++) {
|
|
if (x < 5 || x >= size - 5 || y < 5 || y >= size - 5) {
|
|
const i = (y * size + x) * 4;
|
|
edgeLumSum += 0.2126 * pixels[i] + 0.7152 * pixels[i + 1] + 0.0722 * pixels[i + 2];
|
|
edgeCount++;
|
|
}
|
|
}
|
|
}
|
|
const bgLum = edgeLumSum / edgeCount;
|
|
if (bgLum > 160) {
|
|
invert = true;
|
|
}
|
|
}
|
|
|
|
const gamma = avgLum < 80 ? 0.75 : 1.0;
|
|
|
|
let recommendedCharSet: CharSetKey = 'standard';
|
|
let denoise = false;
|
|
let enhanceEdges = false;
|
|
let overlayStrength = 0.3;
|
|
|
|
const histogramPeaks = countHistogramPeaks(histogram, pixelCount);
|
|
const isHighContrast = activeRange > 180;
|
|
const isLowContrast = activeRange < 80;
|
|
const isBimodal = histogramPeaks <= 3;
|
|
|
|
if (isBimodal && activeRange > 150) {
|
|
recommendedCharSet = 'minimal';
|
|
enhanceEdges = true;
|
|
overlayStrength = 0.1;
|
|
} else if (isHighContrast) {
|
|
recommendedCharSet = 'blocks';
|
|
overlayStrength = 0.2;
|
|
} else if (isLowContrast) {
|
|
recommendedCharSet = 'simple';
|
|
denoise = true;
|
|
overlayStrength = 0.5;
|
|
} else if (activeRange > 100 && activeRange <= 180) {
|
|
recommendedCharSet = 'standard';
|
|
const noiseLevel = estimateNoiseLevel(pixels, size);
|
|
if (noiseLevel > 20) {
|
|
denoise = true;
|
|
}
|
|
}
|
|
|
|
if (meta?.has_fine_detail) {
|
|
recommendedCharSet = 'dots';
|
|
}
|
|
|
|
return {
|
|
exposure: parseFloat(exposure.toFixed(2)),
|
|
contrast,
|
|
invert,
|
|
gamma,
|
|
saturation: parseFloat(saturation.toFixed(1)),
|
|
charSet: recommendedCharSet,
|
|
denoise,
|
|
enhanceEdges,
|
|
overlayStrength
|
|
};
|
|
}
|
|
|
|
function countHistogramPeaks(histogram: number[], pixelCount: number): number {
|
|
const threshold = pixelCount * 0.02;
|
|
let peaks = 0;
|
|
let inPeak = false;
|
|
|
|
for (let i = 1; i < 255; i++) {
|
|
const isPeak = histogram[i] > histogram[i - 1] && histogram[i] > histogram[i + 1];
|
|
const isSignificant = histogram[i] > threshold;
|
|
|
|
if (isPeak && isSignificant && !inPeak) {
|
|
peaks++;
|
|
inPeak = true;
|
|
} else if (histogram[i] < threshold / 2) {
|
|
inPeak = false;
|
|
}
|
|
}
|
|
|
|
return peaks;
|
|
}
|
|
|
|
function estimateNoiseLevel(pixels: Uint8ClampedArray, size: number): number {
|
|
let totalVariance = 0;
|
|
const samples = 100;
|
|
|
|
for (let s = 0; s < samples; s++) {
|
|
const x = Math.floor(Math.random() * (size - 2)) + 1;
|
|
const y = Math.floor(Math.random() * (size - 2)) + 1;
|
|
const i = (y * size + x) * 4;
|
|
|
|
const center = 0.2126 * pixels[i] + 0.7152 * pixels[i + 1] + 0.0722 * pixels[i + 2];
|
|
const neighbors = [
|
|
(y - 1) * size + x,
|
|
(y + 1) * size + x,
|
|
y * size + (x - 1),
|
|
y * size + (x + 1)
|
|
].map(idx => {
|
|
const offset = idx * 4;
|
|
return 0.2126 * pixels[offset] + 0.7152 * pixels[offset + 1] + 0.0722 * pixels[offset + 2];
|
|
});
|
|
|
|
const avgNeighbor = neighbors.reduce((a, b) => a + b, 0) / 4;
|
|
totalVariance += Math.abs(center - avgNeighbor);
|
|
}
|
|
|
|
return totalVariance / samples;
|
|
}
|