Преобразование изображения в ASCII-арт

103

Пролог

Эта тема время от времени всплывает здесь, в Stack Overflow, но обычно удаляется из-за плохо написанного вопроса. Я видел много таких вопросов, а затем молчание со стороны OP (обычно низкая репутация), когда запрашивается дополнительная информация. Время от времени, если вводная информация для меня достаточно хороша, я решаю ответить ответом, и обычно он получает несколько голосов в день, пока активен, но затем через несколько недель вопрос удаляется / удаляется, и все начинается с начало. Поэтому я решил написать эти вопросы и ответы, чтобы я мог напрямую ссылаться на такие вопросы, не переписывая ответ снова и снова ...

Другая причина в том, что этот мета-поток нацелен на меня, поэтому, если вы получили дополнительную информацию, не стесняйтесь комментировать.

Вопрос

Как преобразовать растровое изображение в искусство ASCII с помощью C ++ ?

Некоторые ограничения:

  • полутоновые изображения
  • использование шрифтов с одинарным интервалом
  • сохраняя простоту (не используя слишком сложные вещи для начинающих программистов)

Вот связанная страница Википедии в формате ASCII (спасибо @RogerRowland).

Здесь похожий лабиринт на преобразование ASCII Art Q&A.

Спектр
источник
Используя эту вики-страницу в качестве справочника, можете ли вы уточнить, какой тип ASCII-арта вы имеете в виду? Для меня это звучит как «преобразование изображения в текст», которое представляет собой «простой» поиск от пикселей в градациях серого до соответствующего текстового символа, поэтому мне интересно, имеете ли вы в виду что-то другое. Похоже, вы все равно ответите на него сами ...
Роджер Роуленд,
Связанный: stackoverflow.com/q/26347985/2564301
usr2564301 07
@RogerRowland, как простой (только на основе интенсивности оттенков серого), так и более продвинутый, с учетом формы персонажей (но все же достаточно простой)
Spektre
1
Несмотря на то, что ваша работа великолепна, я, безусловно, был бы признателен за подборку образцов, которые немного больше SFW.
kmote
@TimCastelijns Если вы читаете пролог, то видите, что это не первый раз, когда запрашивается такой тип ответа (и большинство избирателей с самого начала знакомы с несколькими предыдущими вопросами, поэтому остальные просто проголосовали соответственно), поскольку это вопросы и ответы, а не просто В. Я не тратил слишком много времени на часть Q (я признаю, что это ошибка с моей стороны) добавил несколько ограничений на вопрос, если у вас есть лучшие, не стесняйтесь редактировать.
Spektre

Ответы:

153

Есть и другие подходы к преобразованию изображений в ASCII art, которые в основном основаны на использовании монотонных шрифтов . Для простоты я придерживаюсь только основ:

На основе интенсивности пикселей / области (затенение)

Этот подход обрабатывает каждый пиксель области пикселей как одну точку. Идея состоит в том, чтобы вычислить среднюю интенсивность серой шкалы этой точки и затем заменить ее символом с интенсивностью, достаточно близкой к вычисленной. Для этого нам понадобится список используемых персонажей, каждый с заранее вычисленной интенсивностью. Назовем это персонажем 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[]можно рассматривать как линейно распределенный, вызывая небольшое гамма-искажение, обычно невидимое в результате, если вы не знаете, что искать.

Преобразование на основе интенсивности также отлично подходит для изображений в оттенках серого (а не только для черно-белых). Если вы выберете точку как один пиксель, результат станет большим (один пиксель -> один символ), поэтому для больших изображений вместо этого выбирается область (умноженная на размер шрифта), чтобы сохранить соотношение сторон и не увеличивать слишком сильно.

Как это сделать:

  1. Равномерно разделите изображение на (полутоновые) пиксели или (прямоугольные) области, точки s
  2. Вычислить интенсивность каждого пикселя / области
  3. Замените его символом из карты персонажей с наиболее близкой интенсивностью

В качестве персонажа mapвы можете использовать любые символы, но результат улучшается, если у персонажа есть пиксели, равномерно распределенные по области символа. Для начала можно использовать:

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

отсортированы по убыванию и претендуют на линейное распределение.

Таким образом, если интенсивность пикселя / области равна, i = <0-255>то заменяющий символ будет

  • 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;

Вам нужно заменить / игнорировать VCL, если вы не используете среду Borland / Embarcadero .

  • mm_log это памятка, в которой выводится текст
  • bmp это входное растровое изображение
  • AnsiStringэто строка типа VCL, индексированная с 1, а не с 0, как char*!!!

Это результат: Пример изображения с небольшой интенсивностью NSFW

Слева находится изображение ASCII art (размер шрифта 5 пикселей), а справа входное изображение увеличено в несколько раз. Как видите, на выходе больше пиксель -> символ. Если вы используете большие области вместо пикселей, тогда масштаб будет меньше, но, конечно, результат будет менее приятным визуально. Этот подход очень легко и быстро кодировать / обрабатывать.

Когда вы добавляете более сложные вещи, например:

  • автоматизированные вычисления карт
  • автоматический выбор пикселя / размера области
  • корректировка соотношения сторон

Затем вы можете обрабатывать более сложные изображения с лучшими результатами:

Вот результат в соотношении 1: 1 (увеличьте масштаб, чтобы увидеть символы):

Расширенный пример интенсивности

Конечно, при выборке площади вы теряете мелкие детали. Это изображение того же размера, что и в первом примере с областями:

Расширенный пример изображения с небольшой интенсивностью NSFW

Как видите, это больше подходит для изображений большего размера.

Подгонка символов (гибрид между штриховкой и сплошным рисунком ASCII)

Этот подход пытается заменить область (без точек в один пиксель) символом с аналогичной интенсивностью и формой. Это приводит к лучшим результатам даже при использовании шрифтов большего размера по сравнению с предыдущим подходом. С другой стороны, этот подход, конечно, немного медленнее. Есть и другие способы сделать это, но основная идея состоит в том, чтобы вычислить разницу (расстояние) между областью изображения ( dot) и визуализированным символом. Вы можете начать с наивной суммы абсолютной разницы между пикселями, но это приведет к не очень хорошим результатам, потому что даже сдвиг на один пиксель сделает расстояние большим. Вместо этого вы можете использовать корреляцию или другие показатели. Общий алгоритм почти такой же, как и в предыдущем подходе:

  1. Таким образом , равномерно разделить изображение на (полутоновые) прямоугольных областях точки «s

    в идеале с тем же соотношением сторон, что и отображаемые символы шрифта (это сохранит соотношение сторон. Не забывайте, что символы обычно немного перекрываются по оси x)

  2. Вычислите интенсивность каждой области ( dot)

  3. Замените его персонажем персонажа 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();
}

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

Это простая форма application ( Form1) с единственным TMemo mm_txtв ней. Он загружает изображение, "pic.bmp"а затем в зависимости от разрешения выбирает, какой подход использовать для преобразования в текст, который сохраняется "pic.txt"и отправляется в заметку для визуализации.

Для тех, у кого нет VCL, игнорируйте материал VCL и заменяйте его AnsiStringлюбым строковым типом, который у вас есть, а также Graphics::TBitmapлюбым имеющимся у вас классом растрового изображения или изображения с возможностью доступа к пикселям.

Очень важно отметить, что здесь используются настройки mm_txt->Font, поэтому убедитесь, что вы установили:

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

чтобы это работало правильно, иначе шрифт не будет обрабатываться как одинарный. Колесо мыши просто изменяет размер шрифта вверх / вниз, чтобы увидеть результаты для разных размеров шрифта.

[Ноты]

  • См. Визуализацию Word Portraits
  • Используйте язык с возможностью доступа к растровым изображениям / файлам и вывода текста
  • Я настоятельно рекомендую начать с первого подхода, так как он очень простой, понятный и простой, и только затем переходить ко второму (что может быть сделано как модификация первого, поэтому большая часть кода в любом случае остается как есть)
  • Рекомендуется выполнять вычисления с инвертированной интенсивностью (черные пиксели - максимальное значение), поскольку стандартный предварительный просмотр текста находится на белом фоне, что приводит к гораздо лучшим результатам.
  • вы можете поэкспериментировать с размером, количеством и расположением зон подразделения или использовать 3x3вместо этого какую-нибудь сетку .

Сравнение

Наконец, вот сравнение двух подходов к одному и тому же входу:

Сравнение

Зеленая точка , выделенные изображения сделаны с подъездным # 2 и красными с # 1 , всеми по размеру шрифта в шесть пикселей. Как вы можете видеть на изображении лампочки, подход с учетом формы намного лучше (даже если №1 сделан на исходном изображении с 2-кратным увеличением).

Классное приложение

Читая сегодняшние новые вопросы, я получил представление о классном приложении, которое захватывает выбранную область рабочего стола и непрерывно передает ее конвертеру ASCIIart и просматривает результат. После часа написания кода все готово, и я настолько доволен результатом, что мне просто нужно добавить его сюда.

ОК, приложение состоит всего из двух окон. Первое главное окно - это в основном мое старое окно конвертера без выбора изображения и предварительного просмотра (в нем есть все, что указано выше). В нем есть только предварительный просмотр и настройки преобразования ASCII. Второе окно представляет собой пустую форму с прозрачной внутри для выбора области захвата (никакой функциональности).

Теперь по таймеру я просто захватываю выделенную область формой выбора, передаю ее в преобразование и просматриваю ASCIIart .

Таким образом, вы заключаете область, которую хотите преобразовать, в окно выбора и просматриваете результат в главном окне. Это может быть игра, вьювер и т.д. Выглядит это так:

Пример ASCIIart граббера

Так что теперь я могу смотреть даже видео в ASCIIart для развлечения. Некоторые действительно хороши :).

Руки

Если вы хотите попробовать реализовать это в GLSL , взгляните на это:

Спектр
источник
30
Вы проделали здесь невероятную работу! Спасибо! И мне нравится цензура ASCII!
Андер Бигури
1
Предложение по улучшению: отработайте производные по направлению, а не только по интенсивности.
Якк - Адам Неврамонт 08
1
@Yakk хотите уточнить?
tariksbl
2
@tarik либо совпадают не только по интенсивности, но и по производным: либо по краям усиления полосы пропускания. В основном люди видят не только интенсивность: они видят градиенты и грани.
Якк - Адам Неврамонт
1
@Yakk подразделение зон делает это косвенно. Возможно, даже лучше было бы сделать символы в виде 3x3зон и сравнить DCT, но я думаю, это сильно снизит производительность.
Spektre