图像到 ASCII 艺术转换

Image to ASCII art conversion

序言

这个主题有时会在 Stack Overflow 上弹出,但通常会因为问题写得不好而被删除。我看到很多这样的问题,然后在要求提供更多信息时,OP(通常是低代表)保持沉默。如果输入对我来说足够好,我会不时地决定用一个答案来回应,它通常在活跃时每天都会得到一些赞成票,但几周后问题得到 removed/deleted 并且一切都开始了从一开始就。所以我决定写这个Q&A这样我就可以直接参考这样的问题而不用一遍又一遍地重写答案......

另一个原因也是这个 meta thread 针对我,所以如果您有其他意见,请随时发表评论。

问题

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

一些约束:

这是一个相关的维基百科页面 ASCII art(感谢@RogerRowland)。

此处类似问答

有更多的图像到 ASCII 艺术转换的方法,这些方法主要基于使用 等宽字体 。为简单起见,我只坚持基础知识:

Pixel/area 基于强度(阴影)

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

  1. 线性分布强度特征图

    所以我们只使用在相同步长下强度不同的字符。换句话说,当升序排序时:

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

    此外,当我们的字符 map 排序后,我们可以直接从强度计算字符(无需搜索)

     character = map[intensity_of(dot)/constant];
    
  2. 任意分布强度特征图

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

基于强度的转换也适用于灰度图像(不仅仅是黑白图像)。如果您 select 将点作为单个像素,结果会变大(一个像素 -> 单个字符),因此对于较大的图像,区域(字体大小的倍数)被 selected 而不是保留宽高比不要放大太多。

怎么做:

  1. 将图像均匀划分为(灰度)像素或(矩形)区域dots
  2. 计算每个pixel/area
  3. 的强度
  4. 用字符映射中强度最接近的字符替换它

作为字符map,您可以使用任何字符,但如果字符的像素沿字符区域均匀分布,效果会更好。对于初学者,您可以使用:

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

降序排列并假装呈线性分布。

所以如果 pixel/area 的强度是 i = <0-255> 那么替换字符将是

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

如果i==0则pixel/area为黑色,如果i==127则pixel/area为灰色,如果i==255则pixel/area ] 是白色的。您可以在 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;

你需要 replace/ignore VCL 东西,除非你使用 Borland/Embarcadero 环境。

  • mm_log是输出文字的备忘录
  • bmp是输入位图
  • AnsiString 是从 1 开始索引的 VCL 类型字符串,而不是像 char*!!!
  • 那样从 0 开始索引

这是结果:Slightly NSFW intensity example image

左边是ASCII艺术输出(字体大小5像素),右边是输入图像缩放几次。如您所见,输出是较大的像素 -> 字符。如果您使用更大的区域而不是像素,则缩放会更小,但输出的视觉效果当然会更差。 这种方法非常简单快速 code/process。

当您添加更高级的内容时:

  • 自动地图计算
  • 自动 pixel/area尺寸select离子
  • 纵横比校正

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

这是 1:1 比例的结果(缩放以查看字符):

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

Slightly NSFW intensity advanced example image

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

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

这种方法试图用具有相似强度和形状的字符替换区域(不再是单个像素点)。这导致更好的结果,即使与以前的方法相比使用更大的字体。另一方面,这种方法当然有点慢。有更多方法可以做到这一点,但主要思想是计算图像区域 (dot) 和渲染字符之间的差异(距离)。您可以从像素之间绝对差的简单总和开始,但这将导致不是很好的结果,因为即使是一个像素的偏移也会使距离变大。相反,您可以使用相关性或不同的指标。整体算法和前面的方法几乎一样:

  1. 所以把图像均匀的分成(灰度)矩形区域

    最好与 渲染的 字体字符具有相同的纵横比(它将保持纵横比。不要忘记字符通常在 x 轴上有一点重叠)

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

  3. 用最接近intensity/shape

    的字符map中的一个字符代替

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

  1. 字符区域划分

    • 计算转换字母表中每个字符的左、右、上、下和中心区域的单独强度 (map)。
    • 归一化所有强度,因此它们与面积大小无关,i=(i*256)/(xs*ys)
  2. 在矩形区域处理源图像

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

这是字体大小 = 7 像素的结果

如您所见,输出在视觉上令人愉悦,即使使用了更大的字体大小(之前的方法示例是使用 5 像素的字体大小)。输出与输入图像的大小大致相同(无缩放)。获得更好的结果是因为字符更接近原始图像,不仅在强度上,而且在整体形状上,因此您可以使用更大的字体并仍然保留细节(当然达到一定程度)。

下面是基于 VCL 的转换应用程序的完整代码:

//---------------------------------------------------------------------------
#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();
}

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

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

对于那些没有 VCL 的人,请忽略 VCL 内容并将 AnsiString 替换为您拥有的任何字符串类型,并将 Graphics::TBitmap 替换为您拥有的任何位图或图像 class具有像素访问能力。

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

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

使它正常工作,否则字体将不会被处理为等宽。鼠标滚轮只是改变字体大小 up/down 以查看不同字体大小的结果。

[注释]

  • Word Portraits visualization
  • 使用具有 bitmap/file 访问和文本输出功能的语言
  • 我强烈建议从第一种方法开始,因为它非常简单直接,然后才转到第二种方法(可以作为第一种方法的修改来完成,所以大部分代码仍然保持原样)
  • 用反转强度计算(黑色像素是最大值)是个好主意,因为标准文本预览是在白色背景上进行的,因此会产生更好的结果。
  • 您可以尝试细分区域的大小、数量和布局,或者改用 3x3 等网格。

比较

最后是两种方法在同一输入上的比较:

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

酷应用

在阅读今天的新问题时,我想到了一个很酷的应用程序,它可以抓取桌面的 selected 区域并将其连续馈送到 ASCIIart 转换器并查看结果。经过一个小时的编码,它完成了,我对结果非常满意,我必须在这里添加它。

好的,该应用程序仅包含两个 windows。第一个大师 window 基本上是我的旧转换器 window 没有图像 selection 和预览(上面的所有东西都在里面)。它只有 ASCII 预览和转换设置。第二个 window 是一个空的表格,里面是透明的,用于抓取区域 selection(没有任何功能)。

现在在计时器上,我只是通过 selection 形式抓取 selected 区域,将其传递给转换,然后预览 ASCIIart .

所以你用 selection window 包围一个你想转换的区域,然后在 master window 中查看结果。可以是游戏,查看器等,长这样:

所以现在我什至可以在 ASCIIart 中观看视频来获得乐趣。有些真的很好 :).

如果你想尝试在 GLSL 中实现它,请看一下: