Ссылка на GitHub проект

Вступление

На днях я столкнулся с интересным тестовым заданием: нужно было вывести изображение только из черного и белого (bmp 24 или 32 бита). Однако поиски подходящего изображения и готового конвертера не принесли успеха. Это натолкнуло меня на идею: почему бы не создать свой собственный многопоточный конвертер BMP?

У самурая нет цели, только путь... (с) Конфуций / Мао Дзе Дун / Ким Чен Ин

ну или кто-то из них. кстати, на тестовое так и не ответили, так что я все еще ищу работу...

я еще здесь!

Думаю, это кому-то пригодится, по крайней мере в рамках изучения еще одной области применения C++ - обработке изображений. Еще тут есть многопоточность и так далее. В общем все узнаете дальше ->

Начало работы

Исходя из задания, я решил создать консольное приложение, которое реализует:

  • Чтение и отображение изображений в формате BMP.

  • Конвертация цветных изображений в черно-белые.

  • Использование многопоточности для повышения производительности. (потому что с конвертацией грех не использовать)

Реализация

Чтение BMP

Сначала я разработал структуру заголовков BMP-файлов. Для этого использовал директиву #pragma pack, чтобы гарантировать правильное выравнивание. Затем добавил функцию openBMP, которая читает заголовки и пиксели, обрабатывая возможные ошибки.

#pragma pack(push, 1)

struct BMPFileHeader {
    uint16_t fileType{};
    uint32_t fileSize{};
    uint16_t reserved1{};
    uint16_t reserved2{};
    uint32_t offsetData{};
};

struct BMPInfoHeader {
    uint32_t size;
    int32_t width;
    int32_t height;
    uint16_t planes;
    uint16_t bitCount;
    uint32_t compression{};
    uint32_t imageSize{};
    int32_t xPixelsPerMeter{};
    int32_t yPixelsPerMeter{};
    uint32_t colorsUsed{};
    uint32_t colorsImportant{};
};

#pragma pack(pop)
 void openBMP(const std::string &fileName) {
        std::ifstream file(fileName, std::ios::binary);
        if (!file) {
            throw std::runtime_error("Ошибка открытия файла: " + fileName);
        }

        // Чтение заголовков
        file.read(reinterpret_cast<char *>(&fileHeader), sizeof(fileHeader));
        if (file.gcount() != sizeof(fileHeader)) throw std::runtime_error("Ошибка чтения заголовка файла.");

        file.read(reinterpret_cast<char *>(&infoHeader), sizeof(infoHeader));
        if (file.gcount() != sizeof(infoHeader)) throw std::runtime_error("Ошибка чтения заголовка информации.");

        if (infoHeader.bitCount != 24 && infoHeader.bitCount != 32) {
            throw std::runtime_error("Неподдерживаемый формат BMP! Ожидалось 24 или 32 бита.");
        }

        file.seekg(fileHeader.offsetData, std::ios::beg);

        rowStride = (infoHeader.width * (infoHeader.bitCount / 8) + 3) & ~3;
        pixelData.resize(rowStride * infoHeader.height);
        file.read(reinterpret_cast<char *>(pixelData.data()), pixelData.size());
        if (file.gcount() != pixelData.size()) throw std::runtime_error("Ошибка чтения пикселей.");
    }

Проверка цветов

Следующим шагом была функция, проверяющая, есть ли у изображения более двух цветов. Это критически важно, так как если изображение состоит только из черного и белого, его не нужно конвертировать.

    [[nodiscard]] bool hasMoreThanTwoColors() const {
        for (int y = 0; y < infoHeader.height; ++y) {
            for (int x = 0; x < infoHeader.width; ++x) {
                int index = getPixelIndex(x, y);
                uint8_t blue = pixelData[index];
                uint8_t green = pixelData[index + 1];
                uint8_t red = pixelData[index + 2];
                if (!(red == 255 && green == 255 && blue == 255) && !(red == 0 && green == 0 && blue == 0))
                    return true;
            }
        }
        return false;
    }

Конвертация в черно-белый

Для конвертации я разработал метод convertToBlackAndWhite, использующий функционал многопоточности для увеличения скорости обработки. Я определял максимальное количество потоков, доступных на системе, и разбивал работу на равные части. Удобно, не правда ли?

    void convertToBlackAndWhite() {
        auto convertRow = [this](int startRow, int endRow, std::vector<uint8_t> &newPixelData) {
            for (int y = startRow; y < endRow; ++y) {
                for (int x = 0; x < infoHeader.width; ++x) {
                    int index = (y * rowStride) + (x * (infoHeader.bitCount / 8));

                    uint8_t blue = pixelData[index];
                    uint8_t green = pixelData[index + 1];
                    uint8_t red = pixelData[index + 2];

                    double brightness = 0.2126 * red + 0.7152 * green + 0.0722 * blue;

                    if (brightness < brightness_factor) {
                        newPixelData[index] = 0;
                        newPixelData[index + 1] = 0;
                        newPixelData[index + 2] = 0;
                    } else {
                        newPixelData[index] = 255;
                        newPixelData[index + 1] = 255;
                        newPixelData[index + 2] = 255;
                    }
                }
            }
        };

        std::vector<uint8_t> newPixelData = pixelData;

        // Получаем максимальное количество потоков
        unsigned int numThreads = std::thread::hardware_concurrency();
        if (numThreads == 0) numThreads = 1; // Если нет доступного количества потоков, то берем 1
        int rowsPerThread = infoHeader.height / numThreads;
        std::vector<std::future<void> > futures;

        for (unsigned int i = 0; i < numThreads; ++i) {
            int startRow = i * rowsPerThread;
            int endRow = (i == numThreads - 1) ? infoHeader.height : startRow + rowsPerThread;
            // Последний поток берет оставшиеся строки

            futures.push_back(std::async(std::launch::async, convertRow, startRow, endRow, std::ref(newPixelData)));
        }

        for (auto &future: futures) {
            future.get();
        }

        pixelData = std::move(newPixelData);
    } 

Также, как вы видите, я определил некий brightness_factor , что же это за диковина?
Исходя из RGB можно определить освещенность пикселя по формуле 0.2126 * red + 0.7152 * green + 0.0722 * blue , и то, что дает показатель больший 128, я определяю как белый, а меньший - черный. Этот показатель можно изменять, тем самым делая более темные или светлые изображения.

Вот вам идея - сделать реализацию, которая будет учитывать баланс белого и из него устанавливать параметр brightness_factor . У меня же это стандартная середина, никакой коррекции, чистый хардкор)

Не бойтесь, сейчас объясню многопоточнось

Многопоточность

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

1. Определение количества потоков

Первым шагом является определение количества доступных потоков, которое можно получить с помощью функции std::thread::hardware_concurrency(). Эта функция возвращает количество потоков, которые поддерживает система. Если система не может определить это значение, то она возвращает 1. (чтобы все не сломать к чертям)

unsigned int numThreads = std::thread::hardware_concurrency();
if (numThreads == 0) numThreads = 1; // Если нет доступного количества потоков, то берем 1

2. Разделение работы на части

Затем необходимо разбить работу по конвертации на части. Мы можем разделить высоту изображения на количество потоков, чтобы каждый поток обрабатывал свой участок строк изображения.

int rowsPerThread = infoHeader.height / numThreads;

3. Создание потоков

Для создания потоков я использовал std::async, который позволяет запускать функции в асинхронном режиме. Он автоматически управляет жизненным циклом потоков и возвращает std::future, который позволяет ожидать завершения работы потоков.

std::vector<std::future<void> > futures;

for (unsigned int i = 0; i < numThreads; ++i) {
    int startRow = i * rowsPerThread;
    int endRow = (i == numThreads - 1) ? infoHeader.height : startRow + rowsPerThread;
    // Последний поток берет оставшиеся строки

    futures.push_back(std::async(std::launch::async, convertRow, startRow, endRow, std::ref(newPixelData)));
  }

4. Ожидание завершения потоков

После создания и запуска всех потоков необходимо дождаться их завершения. Это можно сделать с помощью метода get() для каждого объекта std::future, который мы сохранили в векторе futures.

for (auto &future: futures) {
  future.get();
}

5. Итоговая замена пикселей

После завершения всех потоков, pixelData заменяется на newPixelData, которая теперь содержит конвертированные пиксели.

pixelData = std::move(newPixelData);

ну и все, тут тоже ничего сложного, если разобраться)

Отображение изображения

Для отображения результата использовал символы # и пробелы, чтобы визуализировать черно-белое изображение в консоли. Да, есть и другие реализации этих цветов, но я выбрал такой простой и наглядный.

 void displayBMP() {
        if (hasMoreThanTwoColors()) {
            std::cout << "Изображение содержит более двух цветов, конвертируем в черно-белое..." << std::endl;
            convertToBlackAndWhite();
        }
        for (int y = infoHeader.height - 1; y >= 0; y -= 2) {
            for (int x = 0; x < infoHeader.width; ++x) {
                int index = getPixelIndex(x, y);
                uint8_t blue = pixelData[index];
                uint8_t green = pixelData[index + 1];
                uint8_t red = pixelData[index + 2];

                std::cout << ((red == 255 && green == 255 && blue == 255) ? WHITE : BLACK);
            }
            std::cout << std::endl;
        }
    }

Все просто) И как видите, как раз тут и происходит проверка на содержание более двух цветов и конвертация, если это необходимо.

Результаты

как без них-то

красавица
красавица
верните стену!
верните стену!

Заключение

В итоге, несмотря на трудности с поиском подходящих изображений, мне удалось создать функциональный и эффективный конвертер BMP. Это не только помогло мне выполнить задание, но и дало возможность изучить многопоточность и работу с графическими форматами.

Спасибо, что дочитал до конца) ❤️

Комментарии (23)


  1. MzMz
    02.10.2024 18:27
    +2

    0.2126 * red + 0.7152 * green + 0.0722 * blue , и то, что дает показатель больший 128, я определяю как белый, а меньший - черный.


    Есть более продвинутый метод переноса остатка (не помню имя метода, курсач делал в 1995 году).

    Допустим текущий пиксел дает значение v=0.2126 * red + 0.7152 * green + 0.0722 * blue в диапазоне от 0 до 255

    При превышении порога в 128 вы не только выводите белый пиксел на этом месте, но также вычисляете остаток d=v-128, который добавляете к соответсвующим v пикселей снизу, справа и справа-снизу по примерной формуле:
    - 0.4 * d добавить к пикселу справа
    - 0.4 * d добавить к пикселу снизу
    - 0.2 * d добавить к пикселу справа-снизу (по диагонали)

    Такой метод сохраняет больше информации (при превышении избыток не выкидывается) и дает более привлекательные изображения, в частности для областей где преобладает значение (для примера) 121, вы вместо пустой черноты выведете некоторую шумовую диффузность, которая издалека будет выглядеть как серый.

    UPD. Внизу написали про дизеринг Флойда-Стейнберга, на Хабре и статья есть https://habr.com/ru/articles/326936/


    1. Fallet Автор
      02.10.2024 18:27

      о да, я думал над этим

      спасибо за замечание, ибо я хотел написать это внутри самой статьи и забыл)

      код сильно бы вырос, а мне не хотелось перегружать его дополнительными алгоритмами

      ну и я хотел показать тут работу с многопоточностью и в целом с пикселями, шумы - это уже мелочи


  1. alef13
    02.10.2024 18:27
    +3

    преобразование по пороговому значению весьма часто дают мерзкую картину - лучше делать дизеринг Флойда-Стейнберга.


    1. Fallet Автор
      02.10.2024 18:27

      я не хотел уходить в такую сложную реализацию


    1. Fallet Автор
      02.10.2024 18:27

      я не хотел уходить в такую сложную реализацию


      1. Fallet Автор
        02.10.2024 18:27

        интернет...


  1. voldemar_d
    02.10.2024 18:27

    void openBMP(const std::string &fileName) {

    Лучше использовать std::string_view вместо const- ссылки на строку.


    1. orefkov
      02.10.2024 18:27

      string_view не даёт гарантий нультерминированнлсти, а значит где-то в глубинах он будет копироваться, ибо API всех ОС ждут C-строку. Так что выгода вряд ли будет.


      1. voldemar_d
        02.10.2024 18:27

        Нультерминированность какое имеет отношение к лишним копированиям?


        1. orefkov
          02.10.2024 18:27

          Символы из переданного string_view будут внутри всё-равно копироваться в string, чтобы в какой-нибудь fopen или CreateFile передать нультерминированную строку. Учтите, я не говорю об общих случаях, когда нет необходимости модифицировать строку или передавать дальше в стороннее C-API - тогда лучше string_view по значению. Но в этом конкретном случае - выгоды нет.


          1. voldemar_d
            02.10.2024 18:27

            Символы из переданного string_view будут внутри всё-равно копироваться в string, чтобы в какой-нибудь fopen или CreateFile передать нультерминированную строку.

            Где про это прочитать можно?


          1. voldemar_d
            02.10.2024 18:27

            Буквально недавно писал процедуру, которая записывает данные в бинарный файл. Примерно так:

            std::error_code writeFile(const std::string_view filePath, uint8_t* data, size_t sz) {
            		std::ofstream outputFile(filePath.data(), std::ios::binary);
            ...

            Под Windows в MSVS (компилятор clang) можно в конструктор std::ofstream передать прямо string_view (видимо, в реализации есть оператор, который приводит string_view к const char*), а под Ubuntu компилятор gcc 13 отказывается такое компилировать, приходится явно передавать data(), а эта функция выдает просто указатель const char* - хотите сказать, здесь тоже какое-то копирование происходит?

            Не помню где - вроде бы, в лекции Полухина из Яндекс про C++17 как раз говорилось про то, что со string_view при передаче в какие-нибудь функции вроде WinAPI нужно быть осторожным, т.к. string_view не обязан содержать нулевой символ в конце. Как раз потому, что никакого копирования при этом не происходит.

            Если я не прав, скажите, где про это подробнее почитать можно.


          1. voldemar_d
            02.10.2024 18:27

            Попробовал написать такой код в приложении на MFC под Windows:

            void test(std::string_view name) {
            	FILE* f;
            	fopen_s(&f, name.data(), "w");
            	if (f) fclose(f);
            	auto h = CreateFileA(name.data(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, 0, nullptr);
            	if (h != INVALID_HANDLE_VALUE)
            		CloseHandle(h);
            }

            Напрямую что в fopen, что в CreateFile передать string_view компилятор не дает, но можно передать указать через вызов data(). Всё собирается и работает, под отладчиком вроде не увидел, что какие-то копирования производятся - только получение указателя при вызове data().

            Вызывается, например, так:

            test("d:\\tmp\\file.txt");

            Внутри функции test вызов name.data() выдает указатель на нуль-терминированную строку. Что я делаю не так?


            1. orefkov
              02.10.2024 18:27

              Вам могут в string_view передать например часть строки, в конце не будет ноля, и всё поломается


              1. voldemar_d
                02.10.2024 18:27

                Это понятно. Я говорю про ситуации, когда я уверен в том, что нуль в конце есть. В таком случае не нужно никакого лишнего копирования.


                1. orefkov
                  02.10.2024 18:27

                  "Все говорят, нельзя баловаться спичками, а я баловался, и ничего не сгорело. Что я делаю не так?"
                  Вы уверены со стороны вызывающего функцию. А когда вы пишите функцию, надо быть уверенным со стороны вызываемого. То есть если вы параметром принимаете string_view, который не даёт гарантии, что за его концом есть 0, а нужно вам передать в C-API, которое требует нуль-терминированную строку, у вас нет никакого другого варианта, кроме как скопировать символы из string_view в string, и вызвать c_str(). До С++11 data() и в std::string не дает гарантий нуль-терминированности.
                  Сегодня вы так напишите, а завтра вашему коллеге дадут задачу "записать мета-инфу о файле в файл с тем же именем, но без расширения", и он напишет
                  write_file({file.name.c_str(), file.name.find_last_of('.')}, .....);
                  И затрет нахрен исходный файл. И будет прав, потому что ваша функция принимает string_view, и он ей посылает string_view, "как договаривались".
                  И да, проверять string_view, что за его концом лежит 0 - UB, как чтение за пределами массива.

                  Из GCC13

                        /**
                         *  @brief  Create an output file stream.
                         *  @param  __s  Null terminated string specifying the filename.
                         *  @param  __mode  Open file in specified mode (see std::ios_base).
                         *
                         *  @c ios_base::out is automatically included in @a __mode.
                         */
                        explicit
                        basic_ofstream(const char* __s,
                  		     ios_base::openmode __mode = ios_base::out)
                  

                  Как видите, параметром он требует Null terminated string specifying the filename.
                  string_view::data не дает гарантий Null terminated.
                  https://en.cppreference.com/w/cpp/string/basic_string_view/data

                  Unlike std::basic_string::data() and string literals, std::basic_string_view::data() returns
                  a pointer to a buffer that is not necessarily null-terminated, for example a substring
                  view (e.g. from remove_suffix). Therefore, it is typically a mistake to pass data() to
                  a routine that takes just a const CharT* and expects a null-terminated string.
                  

                  Так что если уж на то пошло, то лучше передавать filesystem::path.


                  1. voldemar_d
                    02.10.2024 18:27

                     вашему коллеге дадут задачу "записать мета-инфу о файле в файл с тем же именем, но без расширения", и он напишетwrite_file({file.name.c_str(), file.name.find_last_of('.')}, .....);И затрет нахрен исходный файл. И будет прав

                    Коллеге не подвезли std::filesystem, где есть явные функции has_extension() и replace_extension()? Прав ли он, что ими не пользуется?

                    Так-то можно много способов найти, как выстрелить себе в ногу. Например, не проверять указатели на nullptr. Но иногда эти проверки убирают для максимальной производительности, договариваясь о том, что внутрь определенных функций передавать nullptr нельзя.


                    1. orefkov
                      02.10.2024 18:27

                      Какой-то детский лепет и сваливание вины на других, как детский сад, ей богу.
                      write_file - ваша функция. И вы несете ответственность за то, что она будет правильно работать. Вам уже с cppreference привели цитату, что передавать string_view::data в функции, ждущие const CharT* и нуль-терминированную строку - это ошибка, показали случаи, когда это может вызвать проблемы - а Вы всё еще пытаетесь что-то выдавить. Вместо того, чтобы пойти и исправить ошибку в своей функции.
                      Аргументы функции - это контракт, который вы обязуетесь выполнять. Если вы принимаете string_view, то у пользователя вашей функции вот совсем не должна болеть голова о том, будет ли его string_view нуль-терминированным или нет.
                      Принимайте string и не парьтесь. Ну или раз вам подвезли std::filesystem, то filesystem::path.
                      А чтобы не передавали null, надо не "договариваться", а аргументом делать не указатель, а ссылку


                      1. voldemar_d
                        02.10.2024 18:27

                        Вы так на меня наезжаете, будто я уже выложил публичную библиотеку с такой функцией в интерфейсе. И обязую всех ей пользоваться.


                      1. voldemar_d
                        02.10.2024 18:27

                        А чтобы не передавали null, надо не "договариваться", а аргументом делать не указатель, а ссылку

                        Расскажете, как передать ссылку в интерфейс COM-объекта?


                      1. orefkov
                        02.10.2024 18:27

                        "А мы продаём или покупаем"?
                        Вы вызываете чужой COM-интерфейс или реализуете свой?
                        Вообще ничего не мешает в заголовочнике COM-интерфейса прописать в сигнатуре функции ссылку вместо указателя, работать будет. Зато всем нормальным пользователям будет понятно, что функция не ждёт NULL.
                        Вообще, разговор шёл про С++ интерфейсы, которые вы пишете к своему функционалу, а COM, хоть и может вызываться из C++, но всё же скорее некий C-интерфейс.
                        PS: вы не в 1С случайно работаете? А то в 8ке там всё внутри на COM-интерфейсах...


                      1. voldemar_d
                        02.10.2024 18:27

                        А мы не знаем, какое в точности было задание, про это уже ниже задали вопрос. Одно дело - написать публичную библиотеку, которую неизвестно кто вызывает и передаёт туда неизвестно откуда взявшуюся строку. Тогда да - надо принимать строку по const-ссылке.

                        Другое дело, если это функция строго внутри своей программы, и есть определённость по поводу того, откуда строка берётся, и есть ли в ней нулевой символ в конце. Тогда возможен вариант с передачей строк через string_view. Если бояться даже теоретически возможной ситуации, что в string_view будет строка, не оканчивающаяся нулём, так лучше вообще string_view не использовать.

                        Я работаю не в 1C, но так получилось, что приходится и с COM-объектами иметь дело. BSTR, кстати, тоже не обязана ноль в конце содержать. Вообще-то, есть определённые соглашения по поводу того, что можно передавать в COM-объекты, а что нельзя. В outproc-объект тоже сможете из другого EXE-шника ссылку передать?


  1. Serpentine
    02.10.2024 18:27

    Тестовое действительно интересное. Если не запрещено, можете конкретный его текст привести? И на какую позицию претендовали, хотя бы что там требовалось помимо общих C++, ООП и многопоточности?

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

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