refactor(core): modularize ASCII logic and add dither/edge detection
This commit is contained in:
284
src/scripts/ascii-shared.ts
Normal file
284
src/scripts/ascii-shared.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* Shared types, constants, and utilities for ASCII rendering.
|
||||
* Used by both WebGL renderer and UI components.
|
||||
*/
|
||||
|
||||
// ============= Types =============
|
||||
|
||||
export interface AsciiOptions {
|
||||
width?: number;
|
||||
height?: number;
|
||||
contrast?: number;
|
||||
exposure?: number;
|
||||
invert?: boolean;
|
||||
saturation?: number;
|
||||
gamma?: number;
|
||||
charSet?: CharSetKey | string;
|
||||
color?: boolean;
|
||||
dither?: number;
|
||||
edgeMode?: EdgeMode;
|
||||
autoStretch?: boolean;
|
||||
overlayStrength?: number;
|
||||
aspectMode?: AspectMode;
|
||||
denoise?: boolean;
|
||||
fontAspectRatio?: number;
|
||||
onProgress?: (progress: number) => void;
|
||||
}
|
||||
|
||||
export interface AsciiResult {
|
||||
output: string;
|
||||
isHtml: boolean;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export type EdgeMode = 'none' | 'simple' | 'sobel' | 'canny';
|
||||
export type CharSetKey = 'standard' | 'extended' | 'blocks' | 'minimal' | 'matrix' | 'dots' | 'shapes';
|
||||
export type AspectMode = 'fit' | 'fill' | 'stretch';
|
||||
|
||||
export interface ImageMetadata {
|
||||
color_dominant?: [number, number, number];
|
||||
color_palette?: [number, number, number][];
|
||||
has_fine_detail?: boolean;
|
||||
}
|
||||
|
||||
// ============= Constants =============
|
||||
|
||||
export const CHAR_SETS: Record<CharSetKey, string> = {
|
||||
standard: '@W%$NQ08GBR&ODHKUgSMw#Xbdp5q9C26APahk3EFVesm{}o4JZcjnuy[f1xi*7zYt(l/I\\v)T?]r><+"L;|!~:,-_.\' ',
|
||||
extended: '░▒▓█▀▄▌▐│┤╡╢╖╕╣║╗╝╜╛┐└┴┬├─┼╞╟╚╔╩╦╠═╬╧╨╤╥╙╘╒╓╫╪┘┌ ',
|
||||
blocks: '█▓▒░ ',
|
||||
minimal: '#+-. ',
|
||||
matrix: 'ハミヒーウシナモニサワツオリアホテマケメエカキムユラセネスタヌヘ1234567890:.=*+-<>',
|
||||
dots: '⣿⣷⣯⣟⡿⢿⣻⣽⣾⣶⣦⣤⣄⣀⡀ ',
|
||||
shapes: '@%#*+=-:. '
|
||||
};
|
||||
|
||||
export const ASPECT_MODES: Record<string, AspectMode> = {
|
||||
fit: 'fit',
|
||||
fill: 'fill',
|
||||
stretch: 'stretch'
|
||||
};
|
||||
|
||||
export const EDGE_MODES: Record<string, EdgeMode> = {
|
||||
none: 'none',
|
||||
simple: 'simple',
|
||||
sobel: 'sobel',
|
||||
canny: 'canny'
|
||||
};
|
||||
|
||||
// Short keys for UI
|
||||
export const CHARSET_SHORT_MAP: Record<string, CharSetKey> = {
|
||||
STD: 'standard',
|
||||
EXT: 'extended',
|
||||
BLK: 'blocks',
|
||||
MIN: 'minimal',
|
||||
DOT: 'dots',
|
||||
SHP: 'shapes'
|
||||
};
|
||||
|
||||
export const CHARSET_REVERSE_MAP: Record<CharSetKey, string> = Object.fromEntries(
|
||||
Object.entries(CHARSET_SHORT_MAP).map(([k, v]) => [v, k])
|
||||
) as Record<CharSetKey, string>;
|
||||
|
||||
// ============= Auto-Tune =============
|
||||
|
||||
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 : avgLum > 200 ? 1.15 : 1.0;
|
||||
|
||||
let recommendedCharSet: CharSetKey = 'standard';
|
||||
let denoise = false;
|
||||
let dither = 0;
|
||||
let edgeMode: EdgeMode = 'none';
|
||||
let overlayStrength = 0.3;
|
||||
|
||||
const histogramPeaks = countHistogramPeaks(histogram, pixelCount);
|
||||
const isHighContrast = activeRange > 180;
|
||||
const isLowContrast = activeRange < 80;
|
||||
const noiseLevel = estimateNoiseLevel(pixels, size);
|
||||
|
||||
const noiseThreshold = isLowContrast ? 12 : isHighContrast ? 30 : 20;
|
||||
|
||||
const midToneCount = histogram.slice(64, 192).reduce((a, b) => a + b, 0);
|
||||
const hasGradients = midToneCount > pixelCount * 0.6 && histogramPeaks < 5;
|
||||
|
||||
if (isHighContrast || (meta?.has_fine_detail)) {
|
||||
recommendedCharSet = 'extended';
|
||||
overlayStrength = 0.2;
|
||||
if (noiseLevel < noiseThreshold * 0.5) {
|
||||
edgeMode = 'canny'; // Use Canny for high quality clean images
|
||||
}
|
||||
} else {
|
||||
recommendedCharSet = 'standard';
|
||||
}
|
||||
|
||||
if (isLowContrast || noiseLevel > noiseThreshold) {
|
||||
denoise = true;
|
||||
overlayStrength = isLowContrast ? 0.5 : 0.3;
|
||||
// Avoid complex edge detection on noisy images
|
||||
edgeMode = 'none';
|
||||
}
|
||||
|
||||
if (hasGradients && !denoise) {
|
||||
dither = 0.5; // Default dither strength
|
||||
}
|
||||
|
||||
if (noiseLevel > noiseThreshold * 1.5) {
|
||||
dither = 0;
|
||||
denoise = true;
|
||||
}
|
||||
|
||||
return {
|
||||
exposure: parseFloat(exposure.toFixed(2)),
|
||||
contrast,
|
||||
invert,
|
||||
gamma,
|
||||
saturation: parseFloat(saturation.toFixed(1)),
|
||||
charSet: recommendedCharSet,
|
||||
denoise,
|
||||
dither,
|
||||
edgeMode,
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user