Автоматизируем ASCII-графику

Доброе утро!

Разбирая мусор в домашнем каталоге, я наткнулся на забавную утилиту, написанную несколько лет назад при изучении Qt. Занимается программа преобразованием обычных картинок в ASCII-графику. Алгоритм довольно прост, но кому-то может показаться интересным, так что в этой статье речь пойдёт о нём.

Для начала, взглянем на результат:

Как видим, в полученном изображении неплохо передаются градации яркости, видно даже тени.

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

Реализация первого этапа сводится к выполнению следующей функции:

bool ClStats::compareStats(const QPair<char, float> &a, const QPair<char, float> &b) {
    return a.second < b.second;
}

void ClStats::countStats() {
    QGraphicsScene *scn = new QGraphicsScene(this);
    QPixmap pixmap = QPixmap(800, 800);
    QImage img;
    QPainter painter(&pixmap);

    stats.resize(95);

    painter.setBrush(QBrush(QColor(255, 255, 255)));
    painter.setFont(QFont(ui->leFont->text(), 512));
    for (int c = 32; c < 127; ++c) {
        painter.drawRect(QRect(-1, -1, 1100, 1100));
        painter.drawText(QPoint(50, 590), QString((char) c));

        scn->addPixmap(pixmap);
        scn->setSceneRect(pixmap.rect());
        ui->gvChar->setScene(scn);
        ui->gvChar->fitInView(scn->sceneRect(), Qt::KeepAspectRatio);
        this->repaint();

        img = pixmap.toImage();
        int black = 0;
        for (int i = 0; i < 800; ++i) {
            for (int j = 0; j < 800; ++j) {
                if (QColor(img.pixel(i, j)).green() < 128) {
                    ++black;
                }
            }
        }
        stats[c-32] = QPair<char, float>((char) c, black/160000.0);
    }
    std::sort(stats.begin(), stats.end(), compareStats);
    for (int i = 0; i < 95; ++i) {
        ui->tblResults->setItem(i, 0, new QTableWidgetItem(QString::number(stats[i].first)));
        ui->tblResults->setItem(i, 1, new QTableWidgetItem(QString(stats[i].first)));
        ui->tblResults->setItem(i, 2, new QTableWidgetItem(QString::number(stats[i].second)));
    }

    QString st = "";
    for (int i = 0; i < 95; ++i) {
        st.append(stats[i].first);
    }
    ui->leResult->setText(st);
}

Она выполняет заполнение и сортировку массива stats, являющегося списком пар из символа и его относительной яркости путём рассчёта отношения чёрных пикселей символов к белым, при том, что символы отрисовываются одним шрифтом одного размера на одинакового размера белых холстах. После этого получаем строку, где символы расположены в соответствии с их "яркостью". Для DejaVu Sans Mono это такая последовательность:

 .`-,':;_"~^i!*l/\rI()j|?ctf+][JvL<>=7}{zsxY1TunyFok2eahC3V54XPS$qdpbU0AEZK96HgwGR#8m&OD%QBNMW@

Даже если читатель сейчас банально удалится от монитора, он сможет увидеть градиентную линию.

Дальше всё тоже довольно просто. Запоминаем эту строчку (для удобства, я делаю это в обратном порядке):

    stats = " `.-'_,:~\";^!*r\\/+()|<>=?lciv][tzjL7fxs}{YT1JnuCyIFo2%ewVhk3a4Z5SXP$GmAqpbdEU&K69OHg#D0R8QWNBM@";
    std::reverse(stats.begin(), stats.end());

Открываем заданную картинку как QImage и заполняем массив символов, выбирая их в соответствии с яркостью текущего пикселя:

    QImage img;
    QVector< QVector<QChar> > result;

    img.load(ui->leSource->text());
    ui->lScale->setText(
                QString("Width: ") +
                QString::number(img.width()) +
                QString("\nHeight: ") +
                QString::number(img.height()));
    this->repaint();

    result.resize(img.height());
    for (size_t i = 0; i < img.height(); ++i) {
        ui->statusBar->showMessage(
                    QString("row: ") +
                    QString::number(i) +
                    QString(", ") +
                    QString::number(1.0*i/img.height()) +
                    QString("%"));
        this->repaint();

        result[i].resize(img.width());
        for (size_t j = 0; j < img.width(); ++j) {
            result[i][j] = stats[(int) (94 * (QColor(img.pixel(j, i)).value() / 255.0))];
        }
    }

Далее остаётся только сохранить результат в необходимом формате. Я делал это в одном из четырёх видов: текст, HTML, картинка «чёрный на белом», картинка «белый на чёрном». Последние два делаются стандартными методами QPixmap, в который конвертируется QImage, из первых двух приведу пример экспорта в HTML, так как они похожи, но HTML, в моём случае, сохраняет ещё и оригинальный цвет символов:

    QFile fout(filename);
    fout.open(QIODevice::WriteOnly | QIODevice::Text);
    QTextStream res(&fout);
    res << "<pre style=\"font-family: DejaVu Sans Mono, monospace; font-size: 2pt;\">\n";
    for (size_t i = 0; i < data.size(); ++i) {
        for (size_t j = 0; j < data[i].size(); ++j) {
            res << "<span style=\"color: " << QColor(img.pixel(j, i)).name() << "\">" << data[i][j] << "</span>";
        }
        res << "\n";
    }
    res << "</pre>";
    fout.close();

Всё довольно просто и примитивно, Qt использовался для того, чтобы дать приложению графический интерфейс и работать с графикой, но сам алгоритм ни на какие его особенности не завязан. Напоследок, приведу ещё картинок, полученных таким образом:

в полном размере

в полном размере

в полном размере