2025, Nov 25 09:02
Градации серого в Pillow: усечение vs округление и точное повторение
Статья объясняет, почему Pillow 5.4.1 и 11.2.1 дают разные градации серого в Python: смена усечения на округление. Даны точные формулы и код для повторения.
Кажется, что получение одинакового градационного серого в разных версиях библиотеки — задача простая, пока небольшое изменение в правилах округления не начинает менять значения пикселей. Если вы перешли с Pillow 5.4.1 на Python 2.7.18 на Pillow 11.2.1 на Python 3.12.0 и заметили другие результаты при преобразовании в серый, вам не показалось. Разница кроется в том, как библиотека округляет промежуточные вычисления, и зная точную арифметику, вы сможете полностью воспроизвести прежнее поведение в современном Python.
Контекст и минимальный пример
В обоих окружениях загружаются одни и те же данные PNG, но результат в градациях серого расходится. Если в Python 3.12.0 использовать формулу с плавающей точкой L = floor(R * 0.299 + G * 0.587 + B * 0.114), то пиксель (4, 4, 4) может дать 3 из‑за особенностей округления, тогда как в Pillow 5.4.1 для того же входа получалось 4. Когда R == G == B, ожидаемая яркость должна равняться этому значению; любое отклонение — следствие округления.
from math import floor
def fp_luma_approx(r, g, b):
return floor(r * 0.299 + g * 0.587 + b * 0.114)
print(fp_luma_approx(4, 4, 4)) # получено 3 в Python 3.12.0
Что на самом деле считает Pillow
В пользовательской документации упоминаются коэффициенты 0.299/0.587/0.114, но реализация в Pillow 5.4.x использует целочисленную арифметику с масштабированием по степени двойки и побитовым сдвигом. Это не только избегает медленных делений, но, что важнее здесь, фиксирует конкретное поведение при округлении. В основе — целочисленный аккумулятор и сдвиг вправо на 16 бит.
В Pillow 5.4.x внутренняя логика эквивалентна следующему коду на C (имена изменены, поведение идентично):
#define Y24_ACC(px) ((px)[0] * 19595 + (px)[1] * 38470 + (px)[2] * 7471)
static void rgb_to_l_legacy(uint8_t* outbuf, const uint8_t* inbuf, int width)
{
int i;
for (i = 0; i < width; i++, inbuf += 4)
/* Коэффициенты ITU-R 601-2, предполагается нелинейный RGB */
*outbuf++ = Y24_ACC(inbuf) >> 16; /* усечение */
}
В Pillow 11.2.x аккумулятор слегка меняется: перед сдвигом добавляется + 0x8000, что превращает усечение в округление до ближайшего:
#define Y24_ACC_ROUND(px) ((px)[0] * 19595 + (px)[1] * 38470 + (px)[2] * 7471 + 0x8000)
Именно из‑за этого небольшого добавления результаты расходятся. При включённом округлении, например, RGB (0, 1, 0) даёт серое значение 1, а не 0. Во всём пространстве из 16 777 216 RGB‑триплетов различаются 8 388 586 выходов, и расхождение никогда не превышает одного уровня.
Первопричина расхождения
Срабатывают два фактора. Во‑первых, старая реализация использовала целочисленную арифметику с усечением через сдвиг вправо; в новой перед сдвигом добавляется смещение для округления. Во‑вторых, прямая формула с числами 0.299/0.587/0.114 подвержена особенностям двоичного представления чисел с плавающей точкой и может давать значения вроде 3.999999… там, где математически сумма равна 4. В совокупности это объясняет, почему простая «плавающая» аппроксимация в Python 3.12.0 не совпадает с Pillow 5.4.1.
Решение: воспроизвести результаты Pillow 5.4.1 в Python 3.12
Чтобы точно повторить старое преобразование в серый, используйте ту же целочисленную арифметику, что и в Pillow 5.4.x: 16‑битные масштабированные коэффициенты ITU‑R 601, накопление в «широком» целочисленном типе и усечение сдвигом вправо на 16. Пример ниже делает это с NumPy на RGB‑изображении и возвращает одноканальную 8‑битную картинку.
from PIL import Image
import numpy as np
def to_gray_legacy(pil_image):
rgb = np.array(pil_image.convert('RGB'))
r = rgb[:, :, 0].astype(np.uint32)
g = rgb[:, :, 1].astype(np.uint32)
b = rgb[:, :, 2].astype(np.uint32)
acc = r * 19595 + g * 38470 + b * 7471
out8 = (acc >> 16).astype(np.uint8) # усечение, совпадает с Pillow 5.4.x
return Image.fromarray(out8)
Если же вам нужно текущее поведение Pillow, добавьте смещение для округления перед сдвигом:
def to_gray_current(pil_image):
rgb = np.array(pil_image.convert('RGB'))
r = rgb[:, :, 0].astype(np.uint32)
g = rgb[:, :, 1].astype(np.uint32)
b = rgb[:, :, 2].astype(np.uint32)
acc = r * 19595 + g * 38470 + b * 7471 + 0x8000
out8 = (acc >> 16).astype(np.uint8) # округление до ближайшего, совпадает с 11.2.x
return Image.fromarray(out8)
Обе функции сохраняют точную программную логику соответствующих версий библиотеки. Кроме того, они вовсе не используют числа с плавающей точкой, устраняя сюрпризы вроде 3.999999…
Почему это важно
Разница всегда не больше одного уровня, но она может каскадно повлиять на последующие шаги обработки. В картах границ, бинаризации или точном сравнении линий смещение яркости на один уровень способно изменить прохождение порога и, как следствие, набор обнаруженных сегментов. Масштаб тоже немалый: при переходе от усечения к округлению почти половина всех возможных RGB‑триплетов «перескакивает» на соседнее значение.
Если нужна абсолютная воспроизводимость с историческими результатами, проще всего в современном Python повторить старый целочисленный путь. В качестве рабочего обходного варианта можно запускать старый интерпретатор с Pillow 5.4.1 через subprocess и прокидывать пиксели по IPC, но нативное воспроизведение арифметики обычно чище и легче в поддержке.
Выводы
Несовпадение оказалось не случайным багом, а осознанным переходом от усечения к округлению. Аппроксимации 0.299/0.587/0.114 в формате с плавающей точкой не совпадают побитно с целочисленным путём. Нужна точная совместимость с Pillow 5.4.1 — используйте целочисленные коэффициенты 19595, 38470, 7471, накапливайте в широком целочисленном типе и выполняйте сдвиг вправо на 16 без добавочного смещения. Нужна совместимость с современным Pillow — добавляйте 0x8000 перед сдвигом. Понимание того, на каком правиле округления держится ваш конвейер, сэкономит время, стабилизирует результаты и избавит от «минус‑плюс один» сюрпризов.