图像到 ASCII 艺术转换

开场白

这个主题时不时地出现在 Stack Overflow 上,但是它通常因为是一个写得很差的问题而被删除。我看到许多这样的问题,然后沉默从 操作室(通常低代表)时,需要额外的信息。有时候,如果输入对我来说足够好,我决定回答一个问题,通常每天都会有一些赞成票,但是几个星期之后,问题就会被删除,一切从头开始。所以我决定写这个 问与答,这样我就可以直接参考这些问题,而不用一遍又一遍地重写答案..。

另一个原因也是这个 meta thread针对我,所以如果你得到额外的输入,随时发表评论。

提问

如何使用 C + + 将位图图像转换为 ASCII 艺术

Some constraints:

  • 灰度图像灰度图像
  • 使用单间距字体
  • 保持简单(对于初级程序员不要使用太高级的东西)

下面是相关的维基百科页面 ASCII 艺术(感谢@RogerRowland)。

这里类似 maze to ASCII Art conversion的问答。

35749 次浏览

图像到 ASCII 艺术品的转换方法有很多,主要是基于使用 单间距字体单间距字体。为了简单起见,我只坚持基本原则:

基于像素/区域强度(阴影)

此方法将像素区域的每个像素作为单个点处理。这个想法是计算这个点的平均灰度强度,然后用与计算出的强度足够接近的字符替换它。为此,我们需要一些可用的字符列表,每个字符都有一个预先计算的强度。我们称它为字符 map。要更快地选择哪种性格最适合哪种强度,有两种方法:

  1. 线性分布强度特征图

    因此,我们只使用具有强度差异的字符与同一步骤。换句话说,当按升序排序时:

     intensity_of(map[i])=intensity_of(map[i-1])+constant;
    

    同样,当字符 map排序后,我们可以直接从强度计算字符(不需要搜索)

     character = map[intensity_of(dot)/constant];
    
  2. Arbitrary distributed intensity character map

    因此,我们有可用的字符阵列和它们的强度。我们需要找到最接近 intensity_of(dot)的强度。所以,如果我们排序 map[],我们可以使用二进制搜索,否则我们需要一个 O(n)搜索最小距离循环或 O(1)字典。有时为了简单起见,字符 map[]可以作为线性分布处理,导致轻微的伽马失真,通常在结果中看不到,除非您知道要查找什么。

基于亮度的转换对于灰度图像(不仅仅是黑白图像)也很有用。如果你选择点作为单个像素,结果会变大(一个像素-> 单个字符) ,所以对于较大的图像,会选择一个区域(字体大小的倍数)来保持高宽比,并且不会放大太多。

怎么做:

  1. 将图像均匀地划分为(灰度)像素或(矩形)区域
  2. 计算每个像素/区域的亮度
  3. 用角色地图中最接近强度的字符替换它

As the character map you can use any characters, but the result gets better if the character has pixels dispersed evenly along the character area. For starters you can use:

  • char map[10]=" .,:;ox%#@";

排序降序,并假装是线性分布。

So if intensity of pixel/area is i = <0-255> then the replacement character will be

  • map[(255-i)*10/256];

如果 i==0,则像素/区域为黑色,如果 i==127,则像素/区域为灰色,如果 i==255,则像素/区域为白色。你可以在 map[]中尝试不同的角色..。

下面是我在 C + + 和 VCL 中使用的一个古老的例子:

AnsiString m = " .,:;ox%#@";
Graphics::TBitmap *bmp = new Graphics::TBitmap;
bmp->LoadFromFile("pic.bmp");
bmp->HandleType = bmDIB;
bmp->PixelFormat = pf24bit;


int x, y, i, c, l;
BYTE *p;
AnsiString s, endl;
endl = char(13); endl += char(10);
l = m.Length();
s ="";
for (y=0; y<bmp->Height; y++)
{
p = (BYTE*)bmp->ScanLine[y];
for (x=0; x<bmp->Width; x++)
{
i  = p[x+x+x+0];
i += p[x+x+x+1];
i += p[x+x+x+2];
i = (i*l)/768;
s += m[l-i];
}
s += endl;
}
mm_log->Lines->Text = s;
mm_log->Lines->SaveToFile("pic.txt");
delete bmp;

You need to replace/ignore VCL stuff unless you use the Borland/Embarcadero environment.

  • mm_log is the memo where the text is outputted
  • bmp是输入位图
  • AnsiString是从1索引的 VCL 类型字符串,而不是从0索引的 char*! ! !

这就是结果: Slightly NSFW intensity example image

左边是 ASCII 艺术输出(字体大小5像素) ,右边输入图像 变焦了几次。如您所见,输出是较大的像素-> 字符。如果你使用更大的区域而不是像素,那么缩放就会更小,当然输出的视觉效果就不那么令人满意了。这种方法对于编码/处理来说非常容易和快速。

当你添加更高级的东西,如:

  • 自动地图计算自动地图计算
  • 自动像素/区域大小选择
  • aspect ratio corrections

然后你可以处理更复杂的图像,得到更好的结果:

下面是以1:1的比例得到的结果(放大以查看字符) :

Intensity advanced example

当然,对于区域采样,您会丢失一些小细节。这张图片的大小与第一个采样区域的大小相同:

轻微 NSFW 强度高级示例图像

如您所见,这更适合于大图像。

字符拟合(阴影和固体 ASCII 艺术混合)

This approach tries to replace area (no more single pixel dots) with character with similar intensity and shape. This leads to better results, even with bigger fonts used in comparison with the previous approach. On the other hand, this approach is a bit slower of course. There are more ways to do this, but the main idea is to compute the difference (distance) between image area (dot) and rendered character. You can start with naive sum of the absolute difference between pixels, but that will lead to not very good results because even a one-pixel shift will make the distance big. Instead you can use correlation or different metrics. The overall algorithm is the almost the same as the previous approach:

  1. 因此,均匀地划分图像到(灰度)矩形区域

    理想情况下,与 呈现字体字符相同的长宽比(它将保持长宽比。不要忘记字符通常在 x 轴上有一点重叠)

  2. 计算每个区域的强度(dot)

  3. 将其替换为字符 map中的一个字符,并使用最接近的强度/形状

我们如何计算一个字符和一个点之间的距离?这是这种方法中最难的部分。在实验过程中,我在速度、质量和简单性之间做出了妥协:

  1. 将字符区域划分为区域

    Zones

    • 从转换字母表(map)中为每个字符的左、右、上、下和中心区域计算一个单独的强度。
    • Normalize all intensities, so they are independent on area size, i=(i*256)/(xs*ys).
  2. 处理矩形区域中的源图像

    • (使用与目标字体相同的长宽比)
    • 对于每个区域,使用与项目符号 # 1相同的方式计算强度
    • 在转换字母表中从强度中找到最接近的匹配
    • 输出拟合字符

This is the result for font size = 7 pixels

Character fitting example

正如您所看到的,即使使用了更大的字体大小(前一个方法示例使用的是5像素的字体大小) ,输出也是视觉上令人满意的。输出的大小与输入图像大致相同(没有缩放)。取得更好的效果是因为字符更接近原始图像,不仅在强度方面,而且在整体形状方面,因此你可以使用更大的字体并且仍然保留细节(当然在一定程度上)。

Here is the complete code for the VCL-based conversion application:

//---------------------------------------------------------------------------
#include <vcl.h>
#pragma hdrstop


#include "win_main.h"
//---------------------------------------------------------------------------
#pragma package(smart_init)
#pragma resource "*.dfm"


TForm1 *Form1;
Graphics::TBitmap *bmp=new Graphics::TBitmap;
//---------------------------------------------------------------------------




class intensity
{
public:
char c;                    // Character
int il, ir, iu ,id, ic;    // Intensity of part: left,right,up,down,center
intensity() { c=0; reset(); }
void reset() { il=0; ir=0; iu=0; id=0; ic=0; }


void compute(DWORD **p,int xs,int ys,int xx,int yy) // p source image, (xs,ys) area size, (xx,yy) area position
{
int x0 = xs>>2, y0 = ys>>2;
int x1 = xs-x0, y1 = ys-y0;
int x, y, i;
reset();
for (y=0; y<ys; y++)
for (x=0; x<xs; x++)
{
i = (p[yy+y][xx+x] & 255);
if (x<=x0) il+=i;
if (x>=x1) ir+=i;
if (y<=x0) iu+=i;
if (y>=x1) id+=i;


if ((x>=x0) && (x<=x1) &&
(y>=y0) && (y<=y1))


ic+=i;
}


// Normalize
i = xs*ys;
il = (il << 8)/i;
ir = (ir << 8)/i;
iu = (iu << 8)/i;
id = (id << 8)/i;
ic = (ic << 8)/i;
}
};




//---------------------------------------------------------------------------
AnsiString bmp2txt_big(Graphics::TBitmap *bmp,TFont *font) // Character  sized areas
{
int i, i0, d, d0;
int xs, ys, xf, yf, x, xx, y, yy;
DWORD **p = NULL,**q = NULL;    // Bitmap direct pixel access
Graphics::TBitmap *tmp;        // Temporary bitmap for single character
AnsiString txt = "";            // Output ASCII art text
AnsiString eol = "\r\n";        // End of line sequence
intensity map[97];            // Character map
intensity gfx;


// Input image size
xs = bmp->Width;
ys = bmp->Height;


// Output font size
xf = font->Size;   if (xf<0) xf =- xf;
yf = font->Height; if (yf<0) yf =- yf;


for (;;) // Loop to simplify the dynamic allocation error handling
{
// Allocate and initialise buffers
tmp = new Graphics::TBitmap;
if (tmp==NULL)
break;


// Allow 32 bit pixel access as DWORD/int pointer
tmp->HandleType = bmDIB;    bmp->HandleType = bmDIB;
tmp->PixelFormat = pf32bit; bmp->PixelFormat = pf32bit;


// Copy target font properties to tmp
tmp->Canvas->Font->Assign(font);
tmp->SetSize(xf, yf);
tmp->Canvas->Font ->Color = clBlack;
tmp->Canvas->Pen  ->Color = clWhite;
tmp->Canvas->Brush->Color = clWhite;
xf = tmp->Width;
yf = tmp->Height;


// Direct pixel access to bitmaps
p  = new DWORD*[ys];
if (p  == NULL) break;
for (y=0; y<ys; y++)
p[y] = (DWORD*)bmp->ScanLine[y];


q  = new DWORD*[yf];
if (q  == NULL) break;
for (y=0; y<yf; y++)
q[y] = (DWORD*)tmp->ScanLine[y];


// Create character map
for (x=0, d=32; d<128; d++, x++)
{
map[x].c = char(DWORD(d));
// Clear tmp
tmp->Canvas->FillRect(TRect(0, 0, xf, yf));
// Render tested character to tmp
tmp->Canvas->TextOutA(0, 0, map[x].c);


// Compute intensity
map[x].compute(q, xf, yf, 0, 0);
}


map[x].c = 0;


// Loop through the image by zoomed character size step
xf -= xf/3; // Characters are usually overlapping by 1/3
xs -= xs % xf;
ys -= ys % yf;
for (y=0; y<ys; y+=yf, txt += eol)
for (x=0; x<xs; x+=xf)
{
// Compute intensity
gfx.compute(p, xf, yf, x, y);


// Find the closest match in map[]
i0 = 0; d0 = -1;
for (i=0; map[i].c; i++)
{
d = abs(map[i].il-gfx.il) +
abs(map[i].ir-gfx.ir) +
abs(map[i].iu-gfx.iu) +
abs(map[i].id-gfx.id) +
abs(map[i].ic-gfx.ic);


if ((d0<0)||(d0>d)) {
d0=d; i0=i;
}
}
// Add fitted character to output
txt += map[i0].c;
}
break;
}


// Free buffers
if (tmp) delete tmp;
if (p  ) delete[] p;
return txt;
}




//---------------------------------------------------------------------------
AnsiString bmp2txt_small(Graphics::TBitmap *bmp)    // pixel sized areas
{
AnsiString m = " `'.,:;i+o*%&$#@"; // Constant character map
int x, y, i, c, l;
BYTE *p;
AnsiString txt = "", eol = "\r\n";
l = m.Length();
bmp->HandleType = bmDIB;
bmp->PixelFormat = pf32bit;
for (y=0; y<bmp->Height; y++)
{
p = (BYTE*)bmp->ScanLine[y];
for (x=0; x<bmp->Width; x++)
{
i  = p[(x<<2)+0];
i += p[(x<<2)+1];
i += p[(x<<2)+2];
i  = (i*l)/768;
txt += m[l-i];
}
txt += eol;
}
return txt;
}




//---------------------------------------------------------------------------
void update()
{
int x0, x1, y0, y1, i, l;
x0 = bmp->Width;
y0 = bmp->Height;
if ((x0<64)||(y0<64)) Form1->mm_txt->Text = bmp2txt_small(bmp);
else                  Form1->mm_txt->Text = bmp2txt_big  (bmp, Form1->mm_txt->Font);
Form1->mm_txt->Lines->SaveToFile("pic.txt");
for (x1 = 0, i = 1, l = Form1->mm_txt->Text.Length();i<=l;i++) if (Form1->mm_txt->Text[i] == 13) { x1 = i-1; break; }
for (y1=0, i=1, l=Form1->mm_txt->Text.Length();i <= l; i++) if (Form1->mm_txt->Text[i] == 13) y1++;
x1 *= abs(Form1->mm_txt->Font->Size);
y1 *= abs(Form1->mm_txt->Font->Height);
if (y0<y1) y0 = y1; x0 += x1 + 48;
Form1->ClientWidth = x0;
Form1->ClientHeight = y0;
Form1->Caption = AnsiString().sprintf("Picture -> Text (Font %ix%i)", abs(Form1->mm_txt->Font->Size), abs(Form1->mm_txt->Font->Height));
}




//---------------------------------------------------------------------------
void draw()
{
Form1->ptb_gfx->Canvas->Draw(0, 0, bmp);
}




//---------------------------------------------------------------------------
void load(AnsiString name)
{
bmp->LoadFromFile(name);
bmp->HandleType = bmDIB;
bmp->PixelFormat = pf32bit;
Form1->ptb_gfx->Width = bmp->Width;
Form1->ClientHeight = bmp->Height;
Form1->ClientWidth = (bmp->Width << 1) + 32;
}




//---------------------------------------------------------------------------
__fastcall TForm1::TForm1(TComponent* Owner):TForm(Owner)
{
load("pic.bmp");
update();
}




//---------------------------------------------------------------------------
void __fastcall TForm1::FormDestroy(TObject *Sender)
{
delete bmp;
}




//---------------------------------------------------------------------------
void __fastcall TForm1::FormPaint(TObject *Sender)
{
draw();
}




//---------------------------------------------------------------------------
void __fastcall TForm1::FormMouseWheel(TObject *Sender, TShiftState Shift, int WheelDelta, TPoint &MousePos, bool &Handled)
{
int s = abs(mm_txt->Font->Size);
if (WheelDelta<0) s--;
if (WheelDelta>0) s++;
mm_txt->Font->Size = s;
update();
}


//---------------------------------------------------------------------------

它只是一个包含单个 TMemo mm_txt的表单应用程序(Form1)。它加载一个图像,"pic.bmp",然后根据分辨率,选择使用哪种方法转换成文本,这是保存到 "pic.txt"和发送到备忘录可视化。

对于那些没有 VCL,忽略 VCL 的东西,并取代 AnsiString与任何字符串类型,你有,也与任何位图或图像类,你可以处置与像素访问能力的 Graphics::TBitmap

一个非常重要的 注意事项是,它使用 mm_txt->Font的设置,因此请确保设置:

  • Font->Pitch = fpFixed
  • Font->Charset = OEM_CHARSET
  • Font->Name = "System"

使这个工作正常,否则字体将不会被处理为单间距。鼠标滚轮只是改变字体大小的上/下,以查看不同字体大小的结果。

[Notes]

  • See 单词肖像可视化
  • 使用具有位图/文件访问和文本输出功能的语言
  • 我强烈建议从第一种方法开始,因为它非常简单明了,只有在转移到第二种方法(可以对第一种方法进行修改,因此大部分代码仍然保持原样)
  • 由于标准文本预览是在白色背景上进行的,因此使用反向强度(黑色像素是最大值)进行计算是一个好主意,这样可以得到更好的结果。
  • 您可以尝试细分区域的大小、计数和布局,或者使用类似 3x3的网格。

比较

最后,在同一投入的基础上对两种方法进行了比较:

Comparison

绿点标记的图像用方法 # 2完成,红点用 # 1完成,全部采用六像素字体大小。正如您在灯泡图像上看到的,形状敏感的方法要好得多(即使 # 1是在2倍放大的源图像上完成的)。

申请表不错

在阅读今天的新问题时,我想到了一个很酷的应用程序,它可以抓取桌面的一个选定区域,并不断地将其提供给 ASCIIart转换器,然后查看结果。经过一个小时的编码,它完成了,我非常满意的结果,我只需要在这里添加它。

OK the application consists of just two windows. The first master window is basically my old convertor window without the image selection and preview (all the stuff above is in it). It has just the ASCII preview and conversion settings. The second window is an empty form with transparent inside for the grabbing area selection (no functionality whatsoever).

现在,在计时器上,我只是通过选择表单获取选定区域,将其传递给转换,然后预览 ASCIIart

因此,您可以通过选择窗口封闭要转换的区域,并在主窗口中查看结果。它可以是一个游戏,观众等。它看起来像这样:

ASCIIart grabber example

所以现在我甚至可以在 ASCIIart中观看视频来娱乐。有些视频真的很不错:)。

Hands

如果您想尝试在 GLSL中实现这一点,请看下面的代码: