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 после сложения; сделайте так, чтобы переполнения не было изначально: сначала расширьте тип перед сложением, затем при необходимости ограничьте значения.

Выбирая правильный примитив — сложение с насыщением вместо циклического заворачивания — вы делаете численный замысел явным и получаете надёжные результаты.