ASCII 图像先校正字符比例
把照片变成字符画,最直观的办法是把灰度映射到一串字符。可第一次做出来,圆形常常变成长椭圆:问题未必在灰度公式,而在字符本身不是正方形。
等宽字体只保证字符之间等宽,不保证一个字格的宽和高相等。假设实测字格宽为 8 像素、高为 16 像素,一张 120×120 的图若输出 60 列,合理行数约为60×120/120×8/16=30。把行数也设成 60,显示高度就会翻倍。这里的 8/16 是示例参数,需要按实际字体测量。
灰度之前先处理透明背景
Canvas 的像素说明给出了 RGBA 的排列。透明像素可能保留黑色 RGB 数据;直接拿它算灰度,会把本应是白纸的地方变成黑洞。例子先在白色背景上合成,再用加权灰度挑选字符。
权重0.2126/0.7152/0.0722直接作用在这里的字节值上,只是一种常用视觉近似。严格的相对亮度计算还涉及色彩空间和线性化,不能因为用了三个熟悉的系数就称它为物理准确的光照测量。
一个不依赖图片库的核心
下面用一张内存中的圆形测试图演示完整转换,读者可以直接运行node ascii.js。采样采用格子中心的最近邻,因而高频细节会丢失;如果接入真实照片,应先做区域平均或其他降采样,再考虑边缘增强。不要把字符越多解释成还原越准确。
白底上的黑色用@,纯白用空格。输出前不裁掉每行末尾空白,否则等宽版面难以比较。发布到网页时用pre、固定字体和行高,窄屏允许横向滚动。若只放一张字符画截图,还应附上原图说明。
本地验证检查了全透明像素归白、纯黑映射、输出行列数量。没有测试真实图片解码、字体度量、浏览器 Canvas 或图像质量。对这个小工具,我宁可先把圆画圆,再增加“复古滤镜”的名字。
可独立运行的核心例子
const ramp='@%#*+=-:. '; function glyph(r,g,b,a) { const alpha=a/255; const v=(.2126*r+.7152*g+.0722*b)*alpha+255*(1-alpha); return ramp[Math.min(ramp.length-1,Math.floor(v/256*ramp.length))]; } function ascii(data,w,h,cols,cellW=8,cellH=16) { if(data.length!==w*h*4 || w<1 || h<1 || !Number.isInteger(cols) || cols<1) throw Error('尺寸错误'); if(!(cellW>0 && cellH>0)) throw Error('字格尺寸必须为正'); const rows=Math.max(1,Math.round(cols*h/w*cellW/cellH)); return Array.from({length:rows},(_,y) => Array.from({length:cols},(_,x) => { const px=Math.min(w-1,Math.floor((x+.5)*w/cols)); const py=Math.min(h-1,Math.floor((y+.5)*h/rows)); const i=(py*w+px)*4; return glyph(...data.slice(i,i+4)); }).join('')).join('\n'); } const w=120,h=120,data=new Uint8ClampedArray(w*h*4); for(let y=0;y<h;y++) for(let x=0;x<w;x++) { const v=(x-60)**2+(y-60)**2<45**2 ? 0:255; data.set([v,v,v,255],(y*w+x)*4); } const output=ascii(data,w,h,60); if(glyph(0,0,0,0)!==' ' || glyph(0,0,0,255)!=='@') throw Error('灰度错误'); const lines=output.split('\n'); if(lines.length!==30 || lines.some(x=>x.length!==60)) throw Error('比例错误'); console.log(output);