2025, Sep 26 07:18
Как сопоставить пиксели PDF и изображений: numpy+cv2 и DPI
Сделайте PDF и изображения сопоставимыми: почему numpy-кадры не совпадают, как выбрать DPI и применить cv2 для единого ресайза и снижения пиксельных различий.
Когда вы рендерите страницу PDF в массив numpy и делаете то же самое для изображения с идентичным содержимым, естественно ожидать идеального совпадения пикселей. Однако на практике массивы могут заметно расходиться, если конвейеры обработки не согласованы. В этом руководстве показано, откуда берётся несоответствие и как сделать оба пути сопоставимыми, не отказываясь от PDFium.
Базовый сценарий: рендер страницы PDF и загрузка изображения
PDF-путь использует PDFium для растеризации страницы в буфер BGRA, который затем представляется как массив numpy. Путь для изображений использует stb_image для декодирования, изменения размера и удаления альфа-канала до RGB.
py::array_t<uint8_t> page_to_ndarray(FPDF_PAGE pdf_page,
                                      int out_w = 0,
                                      int out_h = 0,
                                      int render_dpi = 80) {
    int px_w, px_h;
    if (out_w > 0 && out_h > 0) {
        px_w = out_w;
        px_h = out_h;
    } else {
        px_w = static_cast<int>(FPDF_GetPageWidth(pdf_page) * render_dpi / 72.0);
        px_h = static_cast<int>(FPDF_GetPageHeight(pdf_page) * render_dpi / 72.0);
    }
    FPDF_BITMAP bmp = FPDFBitmap_Create(px_w, px_h, 1);
    if (!bmp) throw std::runtime_error("Failed to create bitmap");
    FPDFBitmap_FillRect(bmp, 0, 0, px_w, px_h, 0xFFFFFFFF);
    FPDF_RenderPageBitmap(bmp, pdf_page, 0, 0, px_w, px_h, 0, FPDF_ANNOT);
    int row_stride = FPDFBitmap_GetStride(bmp);
    uint8_t* raw_ptr = static_cast<uint8_t*>(FPDFBitmap_GetBuffer(bmp));
    auto out = py::array_t<uint8_t>({px_h, px_w, 4}, raw_ptr); // Буфер в формате BGRA
    FPDFBitmap_Destroy(bmp);
    return out;
}
В Python вывод BGRA преобразуется в RGB за счёт отбрасывания альфа-канала и перестановки каналов.
rgb_from_bgra = bgra_view[:, :, [2, 1, 0]]
Путь для изображений использует stb_image и stb_image_resize: принудительно получает RGBA, масштабирует до целевого разрешения и переводит в RGB.
py::array_t<uint8_t> load_image_rgb(const std::string& file_path,
                                   int out_w = 224,
                                   int out_h = 224) {
    int src_w, src_h, src_c;
    unsigned char* rgba_mem = stbi_load(file_path.c_str(), &src_w, &src_h, &src_c, 4);
    if (!rgba_mem) throw std::runtime_error("Failed to load image");
    std::vector<uint8_t> tmp(out_w * out_h * 4);
    stbir_resize_uint8(rgba_mem, src_w, src_h, 0,
                       tmp.data(), out_w, out_h, 0, 4);
    stbi_image_free(rgba_mem);
    py::array_t<uint8_t> rgb({out_h, out_w, 3});
    auto view = rgb.mutable_unchecked<3>();
    for (int yy = 0; yy < out_h; ++yy) {
        for (int xx = 0; xx < out_w; ++xx) {
            int p = (yy * out_w + xx) * 4;
            view(yy, xx, 0) = tmp[p + 0]; // R — красный
            view(yy, xx, 1) = tmp[p + 1]; // G — зелёный
            view(yy, xx, 2) = tmp[p + 2]; // B — синий
        }
    }
    return rgb;
}
Почему массивы не совпадают
Расхождение возникает из‑за того, что выполняются две принципиально разные операции. Страницу PDF отрисовали сразу в заданный размер (например, 224×224) ради скорости, а изображение декодировали в исходном разрешении и только затем уменьшили. Рендер PDF до целевого размера и растеризация при высоком dpi с последующим даунскейлом — не одно и то же. В итоге даже при одинаковом визуальном содержимом значения пикселей отличаются.
Как сделать конвейеры сопоставимыми
Надёжнее всего сблизить результаты, выровняв стратегию изменения размера. Рендерьте PDF с более высоким dpi, а затем уменьшайте его тем же ресайзером, что используете для изображений. Среди опробованных вариантов наилучшее совпадение по евклидовой дистанции дал cv2, к тому же он работает прямо с массивами numpy. Pillow и кастомный C++‑ресайзер уступили и шли на одном уровне позади.
Полной идентичности это не гарантирует. Даже при одинаковом содержимом расстояние не будет равно нулю; оно уменьшается по мере увеличения dpi рендера PDF. Здесь есть компромисс между производительностью и точностью, так что подберите dpi под свои ограничения.
Единый путь масштабирования с cv2
Цель проста: отрендерить PDF при достаточно высоком dpi, преобразовать BGRA в RGB и изменить размер через cv2. Для массива изображения сделать то же, чтобы оба прошли один и тот же путь numpy→cv2.
Практичный приём — не менять размер изображения в C++ и поручить это cv2, как и для PDF. Вот обновлённый загрузчик, который возвращает RGB в исходном разрешении:
py::array_t<uint8_t> read_image_as_rgb(const std::string& file_path) {
    int iw, ih, ic;
    unsigned char* rgba_buf = stbi_load(file_path.c_str(), &iw, &ih, &ic, 4);
    if (!rgba_buf) throw std::runtime_error("Failed to load image");
    py::array_t<uint8_t> rgb({ih, iw, 3});
    auto dst = rgb.mutable_unchecked<3>();
    for (int y = 0; y < ih; ++y) {
        for (int x = 0; x < iw; ++x) {
            int k = (y * iw + x) * 4;
            dst(y, x, 0) = rgba_buf[k + 0]; // R — красный
            dst(y, x, 1) = rgba_buf[k + 1]; // G — зелёный
            dst(y, x, 2) = rgba_buf[k + 2]; // B — синий
        }
    }
    stbi_image_free(rgba_buf);
    return rgb;
}
Имея оба источника в виде RGB‑массивов numpy, на стороне Python можно обеспечить одинаковое масштабирование с помощью cv2:
import cv2
# PDF: рендер при повышенном dpi, затем конвертация BGRA -> RGB
pdf_bgra = page_to_ndarray(pdf_page, out_w=0, out_h=0, render_dpi=some_dpi)
pdf_rgb = pdf_bgra[:, :, [2, 1, 0]]
pdf_resized = cv2.resize(pdf_rgb, (224, 224))
# Изображение: декодирование в RGB исходного размера и тот же ресайзер cv2
img_rgb = read_image_as_rgb(path_to_image)
img_resized = cv2.resize(img_rgb, (224, 224))
Это заметно сближает численные результаты. По евклидовому расстоянию cv2 дал лучший результат среди испробованных подходов, тогда как Pillow и кастомный C++‑путь слегка уступили.
Почему это важно
Во многих задачах вниз по потоку предполагается, что отрисованный из PDF кадр и растровое изображение с тем же содержимым совпадут по пикселям. На деле стратегия растеризации имеет значение. Согласовав, как и когда вы изменяете размеры, вы получаете осмысленные сравнения и метрики, которые отражают содержимое, а не артефакты конвейера. Если вам нужна высокая численная близость, выбранный dpi для рендера PDF заметно влияет на результат, поэтому его стоит настроить.
Итоги
Прямая отрисовка PDF в целевой размер — это не то же самое, что растеризация при высоком dpi и последующее уменьшение. Уже одно это способно дать «совсем разные массивы». Объедините пути: рендерьте PDF с повышенным dpi, переводите BGRA в RGB и масштабируйте через cv2. Поступайте так же с изображениями, чтобы оба шли через один и тот же путь numpy→cv2. Ожидайте очень близких, но не идентичных результатов и подберите dpi, который сбалансирует производительность и точность для вашей задачи.
Статья основана на вопросе на StackOverflow от Something Something и ответе от Something Something.