2025, Nov 19 18:03
Переполнение uint8 и сложение с насыщением: NumPy против OpenCV
Разбираем переполнение uint8 в NumPy и сложение с насыщением в OpenCV: cv.add, формы Mat vs Scalar, почему np.clip не спасает и как правильно расширять dtype.
Добавлять константы к массивам маленькой разрядности зачастую кажется пустяком — пока не упрёшься в стену беззнакового «заворачивания». Классический пример: массив uint8 плюс 255 для данных, похожих на изображение, должен насыщаться до 255, но арифметика NumPy по умолчанию оборачивается в ноль. Если нужно настоящее поведение с насыщением, это нужно задавать явно.
Воспроизводим переполнение
Начнём с минимального примера с 8-битным массивом. Сложение тихо переполняется, и вместо 255 получается ноль.
import numpy as np
arr_u8 = np.array([1], dtype=np.uint8)
wrapped = arr_u8 + 255
print(wrapped) # array([0], dtype=uint8)
Что на самом деле происходит
NumPy выполняет арифметику в dtype входа, если не указано иначе. Для uint8 допустимые значения — [0, 255]. Когда вы прибавляете 255 к 1 в этом типе, результат 256 нельзя представить, и он оборачивается по модулю 256, давая 0. Применение np.clip постфактум не восстановит ожидаемый результат, потому что переполнение уже случилось. Чтобы избежать этого в чистом NumPy, нужно сделать так, чтобы сложение не переполнялось: то есть заранее расширить dtype перед сложением.
Встроенное сложение с насыщением: OpenCV
В OpenCV математические операции используют арифметику с насыщением. Функция cv.add выполняет поэлементное сложение с насыщением для uint8, поэтому результаты обрезаются до 255 вместо заворачивания.
import numpy as np
import cv2 as cv
img8 = np.array([[1]], dtype=np.uint8) # двумерная форма -> cv::Mat
sat_sum = cv.add(img8, 255)
print(sat_sum) # array([[255]], dtype=uint8)
Семантика формы входных данных имеет значение
Python-обёртки OpenCV преобразуют входы NumPy либо в cv::Mat, либо в cv::Scalar в зависимости от формы — и этот выбор влияет на поведение.
Если вход имеет 2D или 3D форму, он становится cv::Mat. Если форма 1D, он становится cv::Scalar. Если оба аргумента становятся Mat, их формы должны совпадать, иначе получите исключение.
import numpy as np
import cv2 as cv
row_mat = np.uint8([[255, 254, 253]]) # двумерный -> cv::Mat
single_cell = np.uint8([[1]]) # двумерный -> cv::Mat
# Это вызывает исключение, потому что у двух Mat должны совпадать формы
cv.add(row_mat, single_cell)
Если один аргумент — Mat, а другой — Scalar, OpenCV «растягивает» Scalar по всему Mat. Использование одномерного массива во втором аргументе — простой способ получить поведение Scalar.
import numpy as np
import cv2 as cv
row_mat = np.uint8([[255, 254, 253]]) # двумерный -> cv::Mat
scalar_like = np.uint8([1]) # одномерный -> cv::Scalar
out_sat = cv.add(row_mat, scalar_like)
print(out_sat) # array([[255, 255, 254]], dtype=uint8)
Есть несколько важных нюансов, когда задействован Scalar. Scalar — это концептуально 4-элементный вектор, и его элементы шире, чем uint8. Неинициализированные элементы считаются нулями. Прибавление Scalar(1) к изображению влияет только на первый канал, а не на все. Деталей больше, поэтому практическое правило такое: держите хотя бы один аргумент Mat, чтобы получить предсказуемое поведение, как в изображениях.
Нулевомерные массивы (например, np.array(42)) принимаются только если не оба входа — такие значения; иначе семантика снова меняется. Главное: следите, чтобы хотя бы один вход становился cv::Mat.
Почему это важно
Тихое переполнение может испортить последующую логику и метрики качества, особенно в обработке изображений и конвейерах данных, где ожидается отсечение, а не заворачивание. Сложение с насыщением согласуется с тем, как многие системы и кодеки работают с 8-битными данными, поэтому cv.add предотвращает скрытые ошибки и избавляет от лишних проходов для последующего отсечения.
Практические рекомендации
Когда нужна арифметика с насыщением для данных uint8, используйте cv.add и убедитесь, что хотя бы один вход имеет 2D или 3D форму, чтобы он преобразовался в Mat. Если оба входа — Mat, согласуйте формы, чтобы избежать исключений. Если работаете только в NumPy, не полагайтесь на np.clip после сложения; сделайте так, чтобы переполнения не было изначально: сначала расширьте тип перед сложением, затем при необходимости ограничьте значения.
Выбирая правильный примитив — сложение с насыщением вместо циклического заворачивания — вы делаете численный замысел явным и получаете надёжные результаты.