Допустим, вы пишете программу для обработки изображений. Программа получает изображение, преобразует его в значения с плавающей запятой, выполняет обработку и сохраняет изменённые пиксели на диск в виде 8-битных цветов. Сегодня я хочу рассмотреть вопрос преобразования целых значений в значения с плавающей запятой. Существует два решения, которые на Python и NumPy выглядят так:

Стандартное деление на 255

pixels = img / 255.0
result = process(pixels)
output = np.trunc(result * 255 + 0.5)

Альтернативное деление на 256

pixels = (img + 0.5) / 256.0
result = process(pixels)
output = np.trunc(result * 256)

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

# Ограничение и преобразование в 8 бит
output_8bit = output.clip(0, 255).astype(np.uint8)

В стандартном случае целочисленный 0 соответствует 0.0, а 255 соответствует 1.0. Это работает абсолютно нормально и именно так всё реализовано в GPU. В альтернативном случае прибавляется смещение на 0,5 и деление происходит на 256, поэтому целочисленный 0 соответствует 0.5/256=0.001953125. Это неудобно, потому что код обработки изображений, например, без знания константы не сможет обнаруживать чёрные пиксели. Из‑за этого мы привязываем логику к 8-битным значениям, даже если вычисления выполняются с плавающей запятой. В стандартном решении всегда можно предполагать, что чёрный соответствует 0.0.

Однако некоторых программистов всё равно притягивает альтернативное решение. В чём дело? Чем оно им так нравится?

Аргумент против 255.0

На числовой прямой стандартное решение выглядит довольно странно. Ниже показана более наглядная версия с 3-битными числами в интервале [0..7], преобразованными в [0,1]:

На оси X находится числовая прямая, а коричневыми кружками обозначены декодированные значения с плавающей запятой. Числа внутри — это целочисленные входные значения. На каждое целое число указывают стрелки, определяющие интервал значений с плавающей запятой, округляемых до него. Ниже я буду называть эти интервалы бинами.

На краях интервала бины меньше

Первая проблема, сразу заметная на схеме — это выход крайних бинов стандартной формулы за пределы интервала [0,1]. Наверно, эта визуализация не совсем справедлива — в обоих решениях выходные значения ограничиваются, поэтому крайние бины должны уходить в бесконечность, но она чётко показывает, насколько «растянут» стандартный интервал. Растянутый интервал шире, чем предполагаемый при обработке изображений рабочий интервал [0,1].

Это означает, что при преобразовании значений с плавающей запятой в интервале [0,1] обратно в целые крайние бины, по сути, в два раза у́же остальных бинов. Из‑за этого нашему алгоритму будет «сложнее» возвращать крайние значения. Например, если мы генерируем равномерный шум [0,1] и округляем его по стандартной формуле, то значения 0 и 255 будут встречаться вдвое реже, чем другие целые.

Можно проверить это утверждение эмпирически, сгенерировав миллион равномерно распределённых случайных чисел и составив из них гистограмму. На ней будет видно, что 0 и 255 в самом деле вдвое ниже остальных бинов:

Код гистограммы
import numpy as np
import matplotlib.pyplot as plt

result = np.random.uniform(0, 1, 1000000)
final_values = np.trunc(result * 255 + 0.5).clip(0, 255).astype(np.uint8)
plt.hist(final_values, bins=256, range=(0, 255))
plt.show()

Тем не менее, я не могу придумать пример ситуации, в которой этот перекос способен вызвать какие‑то проблемы. Да, числа с плавающей запятой в стандартном решении распределены по более широкому интервалу, но преобразование исходного изображения туда и обратно всё равно происходит без потерь (uint8 → float → uint8).

Кроме того, все получающиеся значения, отличающиеся от 0.0 и 1.0, всё равно будут округляться до нужного бина, выравнивая распределение выходных значений. Вот пример: допустим, наша обработка вычитает 0.005 из цветов с плавающей запятой. При стандартном решении чёрный опускается ниже нуля, выходя за пределы интервала [0,1], а при альтернативном значения остаются положительными. Впрочем, в конечном итоге оба решения возвращают целочисленный 0:

Стандартное решение:
trunc(255 * (-0.005) + 0.5) = 0

Альтернативное решение:
trunc(256 * (0.5 / 256 - 0.005)) = 0

Совершенно не важно, что при стандартном решении бин нуля имеет «половинный размер».

Неточность

Вторая проблема заключается в том, что значения с плавающей запятой в стандартном решении неточные. Например, 128/255.0≈0.501961, но 128/256.0=0.5. Из‑за этой погрешности при округлении расстояния между значениями с плавающей запятой немного варьируются. Но это несерьёзная проблема, потому что погрешность оказывается крошечной. 32-битное число с плавающей запятой имеет 23-битную дробную значащую часть. Погрешность округления затрагивает самый младший бит; колебания происходят с величиной меньше 2−23. Разумеется, относительная погрешность в 0,00001% несущественна даже для самой изощрённой задачи обработки изображений. В данном случае неточность — это вопрос эстетики, а не техническая проблема.

Значения не находятся между целыми

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

Уверен, что существуют области применения, в которых это свойство полезно, хоть и не могу сам подобрать примеры. По крайней мере, в посте «Converting Color Depth» Эндрю Кеслера (известного своим трассировщиком лучей на визитке) говорится, что такая система повышает удобство дизеринга. Он объясняет это так: шум можно складывать, не беспокоясь о пограничных случаях. Неудобные же крайние значения стандартной формулы требуют внимательной обработки для согласования распределения шума.

Два вида квантования

Пока стандартная формула с делением на 255 по‑прежнему выглядит надёжной или хотя бы достаточно оправдывающей своё применение. Можно посмотреть на этот вопрос под другим углом: два решения — это равномерные скалярные квантователи. Открыв страницу Википедии о квантовании, мы поймём, что существует два основных вида квантования:

Большинство равномерных квантователей входных данных со знаком можно разделить на два типа: квантователи с нулевой (mid‑tread) и с ненулевой ступенью (mid‑riser). Названия связаны с тем, что происходит в окрестности значения 0; функция ввода‑вывода квантователя рассматривается в виде лестницы. Квантователи с нулевой ступенью имеют уровень восстановления с нулевым значением (соответствующий ступеньке лестницы), а квантователи с ненулевой ступенью имеют порок классификации с нулевым значением (соответствующий подъёму ступеньки).

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

“Quantization” by Allen Gresho. IEEE Communications Society Magazine, September 1977.
«Quantization» Аллена Грешо. IEEE Communications Society Magazine, сентябрь 1977 года.

На графике квантователи с нулевой и ненулевой ступенью различаются в том, где они пересекают ноль:

Квантователь с нулевой ступенью и в самом деле соотносит ноль с нулём, а квантователь с ненулевой ступенью соотносит ноль с средней точкой между двумя целыми (звучит знакомо?). В выбранной Википедией записи вещественное число на входе обозначено x, его закодированное («классифицированное») целое значение — k, а восстановленное вещественное число — yk​. Формулы соответствующих квантователей выглядят так:

Тип

Классификация (кодирование)

Восстановление (декодирование)

Квантователь с нулевой ступенью

k=trunc(xL+0.5)

yk=k/L

Квантователь с ненулевой ступенью

k=trunc(xL)

yk=(k+0.5)/L

L обозначает количество уровней вывода (например, 256).

Если мы применим эти определения к нашим конкурирующим решениям, то можем назвать стандартную формулу квантователем с ненулевой ступенью и L=255, а альтернативную — квантователем с нулевой ступенью и L=256. Я даже ещё раз покажу их код с новыми обозначениями, чтобы связь стала понятнее. Сами блоки кода остались теми же.

Квантователь с ненулевой ступенью (L=255)

pixels = img / 255.0
result = process(pixels)
output = np.trunc(result * 255 + 0.5)

Квантователь с ненулевой ступенью (L=256)

pixels = (img + 0.5) / 256.0
result = process(pixels)
output = np.trunc(result * 256)

С этой точки зрения можно сказать, что стандартное решение представляет собой странное сочетание квантователя с ненулевой ступенью для беззнаковых входных значений (в цитате говорится про «входные данные со знаком») и L=255. Очевидно, что это неоптимальный выбор для 8-битных входных значений. И всё это ради удобства привязки крайних значений к 0.0 и 1.0. Это подводит нас к ещё одной проблеме стандартной формулы.

Более высокая погрешность квантования

Если бы мы проектировали систему, которая получает вещественное число с равномерным распределением x∈[0,1], кодирует его в 8-битное целое k и воссоздаёт его как ещё одно вещественное число yk​, то стандартная формула была бы пустой тратой ресурсов. Помните, что бины 0 и 255 слегка выходят за края интервала [0,1]? В стандартном решении интервал представляемых значений на самом деле равен [−0.5/255,255.5/255], то есть бины разнесены чуть дальше, чем строго необходимо для входных значений [0,1], что приводит к увеличению погрешности при восстановлении. Впрочем погрешность увеличивается незначительно. Согласно расчётам Питера Мудриевски на StackOverflow, средние абсолютные погрешности для делителей 255 и 256 равны 1/1020 и 1/1024. То есть, теоретически, деление на 256 точнее.

Тонкость в том, что восстановление мы выполняем не так. Сначала мы загружаем 8-битные RGB‑изображения, выполняем их обработку и снова их сохраняем. Мы не можем управлять их квантованием при сохранении; вся потерянная информация утеряна навечно. Иными словами, если цвет изображения был умножен на 255 и округлён, то деление его на 256 во время загрузки не вернёт точность. Только когда мы контролируем и сохранение, и загрузку, имеет смысл стремление к снижению погрешности при восстановлении.

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

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

В заключение

Ответ на вопрос в заголовке статьи будет таким: если вы обрабатываете изображения, взятые из сторонних источников, то значения RGB следует нормализовать на 255. Ни снижение точности значений с плавающей запятой, ни абстрактное представление о более высокой погрешности при восстановлении не будут веской причиной для выбора альтернативного решения. Но если вы контролируете и сохранение, и загрузку изображения, и вам не нужно, чтобы ноль соответствовал нулю, то для повышения точности можно подумать о делении на 256. Только не вините меня, когда ваши коллеги будут загружать ваши изображения при помощи стандартной формулы.

Другие мнения

В статье Джонатана Блоу за 2002 год говорится о квантователях с нулевой и ненулевой ступенью, хоть эти названия в статье и не упоминаются. Идею схемы я взял оттуда.

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

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


  1. kuraga333
    11.08.2026 12:19

    Не читал, но правильный ответ же "прибавить 1 и нормировать на 256"?


    1. thiefsy
      11.08.2026 12:19

      Чему равно #0088FF?