Быстрый старт: основы Python и цифровые изображения

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

Природа цифрового изображения: пиксели, цветовые каналы и системы координат

Природа цифрового изображения: пиксели, цветовые каналы и системы координат

Человеческий глаз воспринимает картину Леонардо да Винчи «Мона Лиза» как плавные переходы полутеней, тончайшие лессировки и неуловимую улыбку. Но если передать эту же картину в алгоритм машинного зрения, никакой магии внутри процессора не произойдёт: алгоритм «увидит» исключительно гигантскую прямоугольную таблицу из миллионов целых чисел.

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

Дискретизация и пиксель: как свет становится числом

Свет в физическом мире непрерывен. Когда фотоны отражаются от поверхности предмета и попадают на кремниевую матрицу цифровой камеры, они взаимодействуют с миллионами светочувствительных элементов — фотодиодов.

Каждый отдельный фотодиод накапливает электрический заряд, пропорциональный количеству упавшего на него света. Затем аналого-цифровой преобразователь (АЦП) измеряет этот заряд и переводит непрерывную аналоговую величину в дискретное число. В этот момент происходят два ключевых процесса:

  1. Пространственная дискретизация (семплирование) — непрерывное оптическое изображение разбивается на сетку крошечных неделимых элементов. Такой базовый элемент называется пикселем (сокращение от английского picture element).
  2. Квантование по уровню — непрерывная физическая яркость округляется до ближайшего целочисленного значения из фиксированного диапазона.

В классической обработке изображений и машинном зрении стандартной глубиной цвета для одного канала является 8 бит (11 байт памяти на один элемент).

28=2562^8 = 256

Пояснение: один байт состоит из 8 бит, каждый из которых может принимать значение 0 или 1. Число 28=2562^8 = 256 задает полное количество уникальных состояний, которые можно закодировать таким объемом данных.

В 8-битном представлении диапазон градаций яркости лежит в пределах от 00 до 255255:

  • Значение 0 соответствует полному отсутствию светового сигнала (абсолютно чёрный цвет).
  • Значение 255 соответствует максимальной насыщенности датчика светом (чистый белый цвет).
  • Значения от 1 до 254 кодируют монотонно возрастающие оттенки серого.

В коде на Python и в библиотеках машинного зрения этот диапазон представляется беззнаковым 8-битным типом данных uint8 (unsigned integer 8-bit). Если вы попытаетесь записать в такой пиксель число 260260, произойдет переполнение типа данных, и значение превратится в 44 (260256260 - 256), что приведет к визуальным артефактам на изображении.

Системы координат: ловушка (X,Y)(X, Y) и строк с колонками

В школьной геометрии и математическом анализе мы привыкли к декартовой системе координат: начало отсчёта (0,0)(0, 0) находится в левом нижнем углу, ось XX направлена вправо, а ось YY — вверх.

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

Начало координат (0,0)(0, 0) в цифровом изображении всегда зафиксировано в левом верхнем углу. Ось XX направлена слева направо (по горизонтали), а ось YY направлена сверху вниз (по вертикали).

Эта традиция берет начало со времён электронно-лучевых трубок (ЭЛТ) первых телевизоров и мониторов, где электронный луч сканировал экран построчно — начиная с верхней левой точки и смещаясь вправо и вниз.

Параметр Декартова система Экранная система координат
Начало отсчёта (0,0)(0,0) Левый нижний угол Левый верхний угол
Ось X Горизонтально вправо Горизонтально вправо
Ось Y Вертикально вверх Вертикально вниз
Квадрант значений Положительные X>0,Y>0X > 0, Y > 0 Только неотрицательные целые числа

Однако здесь инженеров машинного зрения подстерегает главная концептуальная ловушка: несоответствие между экранными координатами (X,Y)(X, Y) и адресацией элементов в двумерных матрицах.

Если изображение имеет ширину WW (Width, количество пикселей по горизонтали) и высоту HH (Height, количество пикселей по вертикали):

  • В терминах геометрических координат точка задается парой (x,y)(x, y), где x[0,W1]x \in [0, W - 1], а y[0,H1]y \in [0, H - 1].
  • В терминах матричной алгебры и массивов данных изображение представляется таблицей, где строки соответствуют высоте, а столбцы — ширине. Обращение к элементу матрицы всегда происходит по правилу: [номер_строки, номер_столбца].

Номер строки (смещение по вертикали) — это координата yy. Номер столбца (смещение по горизонтали) — это координата xx.

Следовательно, для доступа к пикселю с координатами (x,y)(x, y) в программном коде мы обращаемся к ячейке матрицы по индексам [y, x].

Цветовое пространство RGB: как из трёх слоев собрать реальность

Изображение в оттенках серого описывается одной матрицей размера H×WH \times W. Но окружающий мир полон красок. Каким образом сенсоры и алгоритмы кодируют цвет?

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

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

Цветное цифровое изображение — это не одна матрица, а трёхмерный тензор (трехмерный массив), состоящий из трех наложенных друг на друга двумерных плоскостей одинакового разрешения:

  1. Красный канал (R) — матрица H×WH \times W с интенсивностями красного компонента.
  2. Зеленый канал (G) — матрица H×WH \times W с интенсивностями зеленого компонента.
  3. Синий канал (B) — матрица H×WH \times W с интенсивностями синего компонента.

Каждый пиксель цветного изображения кодируется вектором из трех целых чисел: (R,G,B)(R, G, B), где каждое значение лежит в диапазоне от 00 до 255255.

Рассмотрим базовые комбинации:

  • (0, 0, 0) — отсутствие свечения во всех каналах: черный цвет.
  • (255, 255, 255) — максимальная яркость всех трех компонентов: белый цвет.
  • (255, 0, 0) — максимальный красный при выключенных остальных: чистый красный.
  • (255, 255, 0) — сумма красного и зеленого света равной мощности: чистый желтый.
  • (0, 255, 255) — сумма зеленого и синего света: голубой (cyan).
  • (255, 0, 255) — сумма красного и синего света: пурпурный (magenta).
  • (128, 128, 128) — равные промежуточные значения во всех каналах: нейтральный серый.

Если цветное изображение имеет разрешение 1920×10801920 \times 1080 пикселей, его форма в пространстве данных составляет 10801080 строк, 19201920 столбцов и 33 цветовых среза. Общее количество байт, требуемое для хранения такого кадра в несжатом виде, вычисляется прямым перемножением:

1080×1920×3=6220800 байт5,93 МБ1080 \times 1920 \times 3 = 6\,220\,800\text{ байт} \approx 5{,}93\text{ МБ}

Пояснение: высота кадра (10801080) умножается на ширину (19201920) и на число каналов (33). Полученный объем в байтах переводится в мегабайты делением на 1024×1024=10485761024 \times 1024 = 1\,048\,576.

От абстрактных матриц к вычислениям

Итак, любое изображение для инженера машинного зрения — это строгая математическая структура:

  • Разрешение H×WH \times W определяет сетку дискретизации пространства.
  • Глубина цвета (uint8) определяет диапазон квантования яркости от 00 до 255255.
  • Оси координат начинаются в точке (0,0)(0, 0) вверху слева, из-за чего координата xx управляет выбором столбца, а yy — выбором строки.
  • Цветность формируется объединением трех матриц каналов (R, G, B) в трехмерную структуру размера H×W×3H \times W \times 3.

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

В следующей главе мы разберем, как именно этот трехмерный числовой массив размещается в оперативной памяти компьютера с помощью фундаментальной библиотеки научных вычислений Python — NumPy.

Изображение в памяти: представление растровых данных через массивы NumPy

Изображение в памяти: представление растровых данных через массивы NumPy

Если создать в Python список списков размером 1920×10801920 \times 1080 чисел и попробовать инвертировать цвета простым циклом for, интерпретатор потратит на это несколько сотен миллисекунд — частота кадров упадет до 2–3 FPS. Компьютерное зрение в реальном времени требует обработки сотен мегабайт видеопотока за считанные миллисекунды. Как Python, будучи высокоуровневым и относительно медленным языком, справляется с этой задачей? Секрет заключается в том, как именно растровая сетка пикселей размещается в оперативной памяти компьютера с помощью библиотеки NumPy.

Проблема стандартных списков Python

Чтобы понять природу быстродействия массивов, сравним, как устроен стандартный список list в Python и как организован массив numpy.ndarray.

В классическом CPython список — это массив указателей на объекты. Когда мы создаем конструкцию [[r, g, b], ...], в памяти выделяются отдельные независимые объекты:

  • объект внешнего списка;
  • объекты внутренних списков строк;
  • объекты для каждого отдельного пикселя и каждого целого числа.

Каждое стандартное целое число int в Python занимает от 28 байт памяти из-за служебных заголовков интерпретатора. Сами числа разбросаны по разным адресам оперативной памяти. Когда процессор пытается прочитать соседний пиксель, он не находит его в быстром кэше L1/L2/L3 и вынужден постоянно обращаться к относительно медленной оперативной памяти (происходит cache miss).

Библиотека NumPy решает эту проблему фундаментально иначе через структуру ndarray (N-dimensional array).

ndarray состоит всего из двух ключевых компонентов:

  1. Непрерывный блок сырых байтов (Data Buffer) — монолитный фрагмент памяти, где значения пикселей лежат вплотную друг к другу без каких-либо служебных заголовков на каждый элемент.
  2. Заголовок метаданных (Metadata Header) — компактная структура, описывающая, как этот плоский одномерный поток байтов интерпретировать в виде матрицы.

Ключевой инсайт: Для процессора массив ndarray — это единый байтовый блок на языке Си. Векторные инструкции современных процессоров (SIMD: SSE, AVX) могут за один такт загружать и обрабатывать 16, 32 или 64 пикселя одновременно, если они лежат в памяти последовательно.

Анатомия метаданных: Shape, Dtype и Strides

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

1. shape (форма / размерность)

Кортеж целых чисел, задающий количество элементов вдоль каждой оси.

В компьютерном зрении для цветного RGB-изображения принят порядок осей (H,W,C)(H, W, C):

  • HH (Height) — высота изображения (количество строк);
  • WW (Width) — ширина изображения (количество столбцов);
  • CC (Channels) — количество цветовых каналов (обычно 3 для RGB или 1 для градаций серого).
import numpy as np

# Создадим пустое черное RGB-изображение размером 480x640
image = np.zeros((480, 640, 3), dtype=np.uint8)

print(image.shape)  # Выведет: (480, 640, 3)
print(image.ndim)   # Выведет: 3 (трехмерный тензор)

2. dtype (тип данных)

Тип данных определяет, сколько байт отводится на один элемент и как эти биты декодировать.

Для стандартных растровых изображений базовым типом выступает np.uint8:

  • Беззнаковое 8-битное целое число;
  • Размер: ровно 1 байт на значение;
  • Диапазон: от 0 до 255.
Тип данных NumPy Байт на пиксель Диапазон значений Типичное применение в CV
np.uint8 1 байт 02550 \dots 255 Стандартные изображения, маски сегментации
np.float32 4 байта 0.01.00.0 \dots 1.0 (или нормализованный диапазон) Входные данные для нейронных сетей
np.int32 4 байта 2×1092×109-2 \times 10^9 \dots 2 \times 10^9 Идентификаторы объектов, разметка инстансов
np.bool_ 1 байт True / False Бинарные маски дефектов

3. strides (шаги по памяти)

Кортеж, указывающий, сколько байтов нужно пропустить в непрерывном буфере памяти, чтобы перейти к следующему элементу по соответствующей оси.

По умолчанию NumPy использует C-contiguous layout (построчный порядок, row-major order): данные идут слева направо по строке, затем переходят на следующую строку.

Для цветного массива формы (H,W,C)(H, W, C) с типом np.uint8 (где 1 элемент = 1 байт) шаги strides вычисляются по правилу:

strides=(WC,  C,  1)\text{strides} = (W \cdot C, \; C, \; 1)

Поясним каждый элемент этого кортежа:

  • Шаг по строкам (ось 0, yy): чтобы переместиться на пиксель строго под текущим (на следующую строку), нужно перешагнуть всю ширину текущей строки со всеми ее каналами, то есть пропустить WCW \cdot C байт.
  • Шаг по столбцам (ось 1, xx): чтобы переместиться на пиксель справа в той же строке, нужно пропустить все каналы текущего пикселя, то есть CC байт.
  • Шаг по каналам (ось 2, cc): чтобы перейти от красного к зеленому каналу того же пикселя, достаточно сдвинуться на 11 байт.

Для массива формы (480,640,3)(480, 640, 3) типа uint8 свойство image.strides вернет:

(6403,  3,  1)=(1920,  3,  1)(640 \cdot 3, \; 3, \; 1) = (1920, \; 3, \; 1)

Благодаря strides операция разворота изображения или транспонирования осей в NumPy выполняется мгновенно: библиотека просто меняет шаг в метаданных, не копируя гигабайты пикселей в памяти.

Преобразование координат в физический адрес памяти

Когда мы обращаемся к пикселю с координатами (y,x)(y, x) и выбираем канал cc, NumPy вычисляет смещение в плоском буфере памяти по формуле:

Offset=ystride0+xstride1+cstride2\text{Offset} = y \cdot \text{stride}_0 + x \cdot \text{stride}_1 + c \cdot \text{stride}_2

Разберем параметры формулы:

  • yy — индекс строки (вертикальная координата);
  • xx — индекс столбца (горизонтальная координата);
  • cc — индекс канала (00 для R, 11 для G, 22 для B);
  • stride0,stride1,stride2\text{stride}_0, \text{stride}_1, \text{stride}_2 — шаги по соответствующим осям в байтах.

Пример расчета: Пусть у нас есть изображение с формой (480,640,3)(480, 640, 3) и шагами (1920,3,1)(1920, 3, 1). Нам нужен синий канал (c=2c = 2) пикселя, находящегося в строке y=100y = 100 и столбце x=50x = 50:

Offset=1001920+503+21=192000+150+2=192152 байт\text{Offset} = 100 \cdot 1920 + 50 \cdot 3 + 2 \cdot 1 = 192000 + 150 + 2 = 192152 \text{ байт}

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

import numpy as np

# Инициализируем синтетическое изображение
img = np.zeros((480, 640, 3), dtype=np.uint8)

# Установка цвета пикселя (y=100, x=50) в ярко-красный (R=255, G=0, B=0)
img[100, 50, 0] = 255  # Красный канал
img[100, 50, 1] = 0    # Зеленый канал
img[100, 50, 2] = 0    # Синий канал

# Либо присвоение сразу всего среза каналов:
img[100, 50] = [255, 0, 0]

Дисциплина типов: арифметика и переполнение в np.uint8

Работа с сырыми байтами требует строгого контроля диапазонов. Главная ловушка для начинающих инженеров компьютерного зрения — циклическое переполнение беззнаковых чисел (integer overflow).

Поскольку тип np.uint8 ограничен 88 битами, максимальное значение в нем равно 281=2552^8 - 1 = 255. При выходе за границы диапазона происходит взятие значения по модулю 256256:

Vactual=V(mod256)V_{\text{actual}} = V \pmod{256}

где:

  • VV — результат математической операции;
  • VactualV_{\text{actual}} — итоговое значение, сохраненное в памяти.

Например, если к яркости пикселя со значением 250250 прибавить 1010:

250+10=260    260(mod256)=4250 + 10 = 260 \implies 260 \pmod{256} = 4

Пиксель, который должен был стать ослепительно белым, превратится в почти черный.

import numpy as np

bright_pixel = np.array([250], dtype=np.uint8)
result = bright_pixel + np.uint8(10)

print(result)  # Выведет [4], а не [260]!

Аналогично при вычитании (underflow):

510=5    5(mod256)=2515 - 10 = -5 \implies -5 \pmod{256} = 251

Попытка немного затемнить темный пиксель приведет к появлению максимальной яркости (шумового артефакта).

Правило обработки: Любые промежуточные математические операции с пикселями (фильтрация, нормализация, изменение контраста) переводят массив в np.float32 или np.int32, применяют ограничивающую функцию (clipping в диапазон 02550 \dots 255) и только перед выводом или сохранением возвращают тип np.uint8.

# Безопасная корректировка яркости
img_float = img.astype(np.float32)
img_bright = img_float + 50.0

# Обрезаем значения, выходящие за границы [0, 255], и возвращаем uint8
img_safe = np.clip(img_bright, 0, 255).astype(np.uint8)

Теперь, когда структура хранения растровых данных в оперативной памяти ясна, мы готовы перейти к чтению реальных графических файлов (JPEG, PNG) с диска и преобразованию их в массивы NumPy.

Загрузка, визуализация и сохранение графических файлов с помощью Python

Заголовок статьи

Загрузка, визуализация и сохранение графических файлов с помощью Python

Вы загружаете фотографию ярко-красного спорткара в массив NumPy с помощью библиотеки OpenCV, передаёте её в Matplotlib для отрисовки — и на экране появляется синий автомобиль с неестественно смуглым водителем. Данные не повреждены, массив содержит ровно те же числа, что были на диске, но цвета полностью перепутаны.

Эта ошибка — своеобразный «обряд посвящения» каждого инженера компьютерного зрения. Чтобы массив ndarray в оперативной памяти стал видимым изображением или превратился в файл .jpg, необходимо связать воедино алгоритмы декодирования, стандарты порядка цветовых каналов и механизмы вывода на дисплей.

От файла на диске к массиву в памяти: мост декодирования

Файл на диске (.jpg, .png, .webp) — это не готовый массив пикселей, а сильно сжатая бинарная последовательность.

  • JPEG использует алгоритмы дискретного косинусного преобразования (DCT) и квантования, сжимая данные с потерями (lossy). В файле хранятся частотные коэффициенты блоков 8×88 \times 8 пикселей.
  • PNG применяет фильтрацию предсказания строк и алгоритм Deflate (LZ77 + код Хаффмана), обеспечивая сжатие без потерь (lossless).

Когда Python обращается к файлу, задача библиотеки — прочитать заголовок метаданных, распаковать сжатый поток и развернуть его в несжатый буфер ndarray с формой (H, W, C) и типом uint8.

В экосистеме Python для этой задачи чаще всего применяются три инструмента:

  1. OpenCV (cv2) — стандарт де-факто для высокопроизводительного компьютерного зрения, написанный на C++. Быстро декодирует форматы через встроенные системные библиотеки (libjpeg, libpng).
  2. Pillow (PIL) — классическая библиотека для базовой обработки графики. Работает со своими объектами PIL.Image, требуя явного приведения к NumPy через np.array(img).
  3. Matplotlib (matplotlib.image) — модуль для визуализации графиков и данных, умеющий загружать растровые файлы напрямую в массивы.

Сравним поведение этих библиотек при чтении одного и того же файла:

Библиотека Функция чтения Возвращаемый тип Порядок каналов Особенности
OpenCV cv2.imread(path) numpy.ndarray BGR При ошибке пути возвращает None, не бросая исключение
Pillow Image.open(path) PIL.JpegImagePlugin... RGB «Ленивая» загрузка: читает метаданные, пока не запрошены пиксели
Matplotlib plt.imread(path) numpy.ndarray RGB Для PNG может возвращать float32 в диапазоне [0.0,1.0][0.0, 1.0]

Ловушка BGR: почему OpenCV видит мир иначе

Исторически библиотека OpenCV разрабатывалась компанией Intel в конце 1990-х годов. В те времена аппаратные графические ускорители, видеотюнеры и стандартные фреймбуферы операционной системы Windows (включая структуры BITMAPINFOHEADER) хранили цветовые байты в памяти в обратном порядке: Blue, Green, Red. Разработчики OpenCV оптимизировали операции под архитектуру процессоров Intel и стандарты видеозахвата Video for Windows, зафиксировав порядок BGR в качестве формата по умолчанию.

Подавляющее большинство современных библиотек (Matplotlib, Pillow, PyTorch, TensorFlow, веб-браузеры) используют стандартный порядок RGB.

Когда трехмерный массив (H, W, 3) из OpenCV передается функции plt.imshow(), Matplotlib интерпретирует нулевой срез [:, :, 0] как интенсивность красного канала, а второй [:, :, 2] — как синий. В результате красный и синий каналы меняются местами:

  • Чистый красный цвет (R=255,G=0,B=0)(R=255, G=0, B=0) в представлении OpenCV сохраняется как (0,0,255)(0, 0, 255).
  • Matplotlib читает первый байт (00) как красный, а третий (255255) как синий. Изображение окрашивается в ярко-синий цвет.
  • Зеленый канал [:, :, 1] остается на своем месте и отображается корректно.

Преобразование цветовых каналов

Чтобы привести массив OpenCV к стандарту RGB, используют два метода:

import cv2
import numpy as np

# Загрузка изображения с диска в формате BGR
img_bgr = cv2.imread("sample.jpg")

# Способ 1: Использование специализированной функции OpenCV (C++ оптимизация)
img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)

# Способ 2: Срез NumPy по последней оси (разворот порядка каналов)
img_rgb_slice = img_bgr[:, :, ::-1]

Оба метода дают идентичный по значениям результат. Метод cv2.cvtColor выполняет явное копирование данных в новый буфер с перестановкой байт, а срез [::-1] создает представление (view) с отрицательным шагом по оси каналов (strides).

Отображение изображений: Matplotlib и OpenCV GUI

Для интерактивной разработки, визуализации промежуточных этапов в Jupyter Notebooks и отладки алгоритмов машинного зрения применяют библиотеку matplotlib.pyplot.

import cv2
import matplotlib.pyplot as plt

# Чтение и конвертация
image_bgr = cv2.imread("robot_camera.jpg")
image_rgb = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB)

# Отображение
plt.figure(figsize=(8, 6))
plt.imshow(image_rgb)
plt.title("Визуализация кадра с камеры")
plt.axis("off")  # Отключение координатных осей
plt.show()

Специфика полутоновых (Grayscale) изображений

При загрузке одноканального полутонового изображения флагом cv2.IMREAD_GRAYSCALE мы получаем двумерную матрицу формы (H, W). Если передать такой массив в plt.imshow(), библиотека по умолчанию применит псевдоцветовую карту Viridis (зелено-желто-фиолетовые оттенки), а не градации серого.

Для корректного отображения яркости необходимо явно указать цветовую карту cmap='gray':

gray_img = cv2.imread("robot_camera.jpg", cv2.IMREAD_GRAYSCALE)

plt.imshow(gray_img, cmap="gray")
plt.title(f"Grayscale: shape {gray_img.shape}")
plt.show()

Сохранение массивов на диск: cv2.imwrite и параметры сжатия

Обратный процесс — сериализация массива ndarray в графический файл — выполняется функцией cv2.imwrite(filename, img, params).

Критическое правило: функция cv2.imwrite() ожидает, что входной массив находится в пространстве BGR (или является одноканальным полутоновым). Если перед сохранением вы перевели массив в RGB для отрисовки в Matplotlib, его необходимо вернуть в BGR, иначе на диске окажется файл с инвертированными цветами.

Функция принимает опциональный список параметров, определяющих качество и алгоритм компрессии:

# 1. Сохранение в JPEG с заданным качеством (от 0 до 100, по умолчанию 95)
# Снижение качества уменьшает размер файла ценой появления артефактов блочности
cv2.imwrite(
    "output_compressed.jpg",
    image_bgr,
    [cv2.IMWRITE_JPEG_QUALITY, 85]
)

# 2. Сохранение в PNG с уровнем сжатия без потерь (от 0 до 9, по умолчанию 3)
# Уровень влияет только на время работы алгоритма Deflate и размер файла, пиксели не меняются
cv2.imwrite(
    "output_lossless.png",
    image_bgr,
    [cv2.IMWRITE_PNG_COMPRESSION, 6]
)

При работе с cv2.imwrite расширение файла в строке пути (.jpg, .png, .tiff) определяет, какой кодек будет вызван внутри C++ ядра OpenCV. Если указать расширение .png, но передать флаг IMWRITE_JPEG_QUALITY, параметр будет проигнорирован.

Сквозной пайплайн: чтение, валидация и экспорт

Объединим рассмотренные концепции в надежный шаблон предварительной обработки данных. В продакшн-коде машинного зрения критически важно проверять факт успешного чтения файла: если путь указан неверно или файл поврежден, cv2.imread не генерирует исключение FileNotFoundError, а возвращает значение None.

from pathlib import Path
import cv2
import matplotlib.pyplot as plt

def process_and_inspect_image(input_path: Path, output_path: Path) -> None:
    # 1. Загрузка данных
    bgr_data = cv2.imread(str(input_path))

    # Защитная проверка: предотвращение ошибок в последующих операциях
    if bgr_data is None:
        raise FileNotFoundError(f"Не удалось загрузить изображение по пути: {input_path}")

    # 2. Преобразование для визуализации
    rgb_data = cv2.cvtColor(bgr_data, cv2.COLOR_BGR2RGB)

    # 3. Инспекция метаданных массива
    height, width, channels = bgr_data.shape
    print(f"Разрешение: {width}x{height}, Каналов: {channels}, Тип: {bgr_data.dtype}")

    # 4. Визуализация в процессе отладки
    plt.figure(figsize=(6, 4))
    plt.imshow(rgb_data)
    plt.title("Корректное цветовое пространство (RGB)")
    plt.axis("off")
    plt.tight_layout()
    plt.show()

    # 5. Экспорт обработанного кадра на диск (подаем BGR!)
    success = cv2.imwrite(str(output_path), bgr_data, [cv2.IMWRITE_JPEG_QUALITY, 90])
    if not success:
        raise IOError(f"Ошибка записи в файл: {output_path}")

# Пример вызова
# process_and_inspect_image(Path("input.jpg"), Path("output.jpg"))

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

Срезы и геометрия матрицы: кадрирование и выделение областей интереса (ROI)

Срезы и геометрия матрицы: кадрирование и выделение областей интереса (ROI)

Если нейросеть для контроля качества находит трещину на автомобильном стекле, алгоритм распознавания номеров детектирует автомобиль, а медицинский сканер фиксирует патологию в тканях — ни одна из этих систем не обрабатывает весь кадр целиком на финальном этапе. Полноразмерный кадр высокого разрешения передаёт общую сцену, но ключевое решение принимается на компактном фрагменте размером 100×100100 \times 100 или 50×20050 \times 200 пикселей.

В компьютерном зрении такой целевой фрагмент называют Region of Interest (область интереса, или ROI). Поскольку декодированное изображение хранится в оперативной памяти как массив NumPy, операция кадрирования или извлечения ROI не требует специализированных графических библиотек. Вся работа сводится к прямому срезу многомерной матрицы. Однако простота синтаксиса срезов скрывает опасную архитектурную ловушку управления памятью.


Анатомия матричного среза: перевод геометрии кадра в индексы

В системах детекции объектов координаты прямоугольной области традиционно задаются в экранной системе координат: верхний левый угол (x,y)(x, y), ширина ww (width) и высота hh (height). Либо парой точек: (xmin,ymin)(x_{min}, y_{min}) и (xmax,ymax)(x_{max}, y_{max}).

При переходе к срезу массива NumPy порядок осей инвертируется. Первое измерение массива отвечает за строки (вертикаль, ось YY), второе — за столбцы (горизонталь, ось XX), а третье — за каналы:

roi = image[y_min:y_max, x_min:x_max]

Если область задана через начальную точку (x,y)(x, y) и габариты (w,h)(w, h), формула среза принимает следующий вид:

roi = image[y : y + h, x : x + w]

При работе со срезами в NumPy действуют три строгих правила:

  1. Полуинтервал индексации: левая граница включается в срез, а правая — исключается. Диапазон [100:250] по вертикали содержит ровно 250100=150250 - 100 = 150 строк пикселей (с индексами от 100 до 249 включительно).
  2. Третье измерение (каналы): если срез каналов не указан явно (например, image[100:250, 50:200]), NumPy автоматически выбирает все доступные каналы, что эквивалентно записи image[100:250, 50:200, :].
  3. Сохранение размерности: срез по диапазону y_start:y_end всегда сохраняет 3D-структуру массива для цветного кадра (Hroi,Wroi,3)(H_{roi}, W_{roi}, 3) или 2D для полутонового (Hroi,Wroi)(H_{roi}, W_{roi}).

Разберём конкретный пример: выделим дорожный знак на Full HD кадре с размерами 1080×19201080 \times 1920 пикселей:

import cv2

# Загружаем изображение
frame = cv2.imread("traffic_scene.jpg")

# Координаты детектора: x=1200, y=300, ширина w=150, высота h=150
x, y, w, h = 1200, 300, 150, 150

# Вырезаем область интереса (строки: от 300 до 450, столбцы: от 1200 до 1350)
sign_roi = frame[y : y + h, x : x + w]

print(f"Размер исходного кадра: {frame.shape}")
print(f"Размер выделенного знака: {sign_roi.shape}")
# Вывод:
# Размер исходного кадра: (1080, 1920, 3)
# Размер выделенного знака: (150, 150, 3)

Архитектурная ловушка: View против Copy

Когда вы создаёте срез roi = frame[y1:y2, x1:x2], NumPy не выделяет новую память и не копирует пиксели. Вместо этого создаётся так называемое представление (View).

Ключевой инсайт: Срез в NumPy — это легковесный заголовок метаданных, который ссылается на тот же самый непрерывный буфер памяти исходного массива, но с изменённой точкой смещения (offset) и скорректированными границами shape.

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

Чтобы модификация вырезанного фрагмента не затронула исходный кадр, необходимо принудительно разорвать связь с общим буфером с помощью метода .copy():

# Вариант 1: Опасная мутация оригинала (View)
frame = cv2.imread("sample.jpg")
roi_view = frame[50:150, 50:150]
roi_view[:, :, :] = 0  # Исходный кадр 'frame' получит черный квадрат в зоне [50:150, 50:150]

# Вариант 2: Безопасная изолированная обработка (Copy)
frame = cv2.imread("sample.jpg")
roi_copy = frame[50:150, 50:150].copy()
roi_copy[:, :, :] = 0  # Исходный кадр 'frame' остался полностью нетронутым

Различие между этими механизмами сведено в таблицу:

Характеристика Срез-представление (View) Явная копия (.copy())
Расход оперативной памяти 00 байт (только заголовок структуры) Hroi×Wroi×CH_{roi} \times W_{roi} \times C байт
Время выполнения O(1)O(1) (константное) O(N)O(N) (зависит от числа пикселей)
Влияние на исходный кадр Любые изменения меняют оригинал Полная изоляция данных
Типичный сценарий Чтение данных, инференс нейросети, замена фрагмента на месте Аугментация, фильтрация, отрисовка графики поверх фрагмента

Вставка и модификация фрагментов: Patching

Свойство срезов модифицировать исходный буфер по месту превращается в мощный инструмент, когда требуется вставить обработанный фрагмент обратно в кадр, наложить маску или скомпоновать коллаж.

Операция вставки требует строгого соблюдения размерностей: форма вставляемого массива должна в точности совпадать с формой среза-приёмника по всем трём осям (H,W,C)(H, W, C).

import numpy as np

canvas = np.zeros((600, 800, 3), dtype=np.uint8)

# Создаем тестовый патч 100x100 пикселей белого цвета
patch = np.full((100, 100, 3), 255, dtype=np.uint8)

# Вставляем патч в координаты y=200..300, x=350..450
canvas[200:300, 350:450] = patch

Если размеры среза и вставляемого блока разойдутся хотя бы на 1 пиксель (например, попытка присвоить массив 100×101100 \times 101 в срез 100×100100 \times 100), интерпретатор выбросит ошибку:

ValueError: could not broadcast input array from shape (100,101,3) into shape (100,100,3)

Поканальное кадрирование и пространственные преобразования через срезы

Срезы позволяют адресовать не только пространственные координаты, но и отдельные цветовые каналы. Это открывает возможность изолировать или заменять спектральные плоскости без создания промежуточных копий:

# Выделение синего канала только в заданной ROI (без копирования остальных каналов)
blue_roi = frame[100:200, 100:200, 0]

# Полное обнуление красного канала в определенной зоне кадра (BGR: индекс 2)
frame[100:200, 100:200, 2] = 0

С помощью отрицательного шага среза ::-1 можно выполнять базовые геометрические трансформации без вызова специализированных функций трансформации:

# Зеркальное отражение по вертикали (переворот строк)
flipped_vertical = frame[::-1, :, :]

# Зеркальное отражение по горизонтали (переворот столбцов)
flipped_horizontal = frame[:, ::-1, :]

# Одновременный переворот по обеим осям (поворот на 180 градусов)
rotated_180 = frame[::-1, ::-1, :]

Граничные случаи: выход за пределы кадра и защита данных

В реальных пайплайнах детекторы объектов нередко выдают координаты bounding box, выходящие за физические границы матрицы (например, отрицательные значения xmin<0x_{min} < 0 или координаты правого края xmax>Wx_{max} > W, если объект частично скрылся за границей кадра).

В Python синтаксис срезов списков и массивов не выбрасывает исключение IndexError при выходе за пределы диапазона. Массив просто неявно обрезается до доступных физических границ:

img = np.zeros((100, 100, 3), dtype=np.uint8)

# Запрос среза, выходящего за пределы матрицы
cropped = img[50:150, 50:150]

print(cropped.shape)
# Вывод: (50, 50, 3), а не (100, 100, 3)!

Такое поведение опасно: нейросеть, ожидающая на входе тензор строго фиксированного разрешения 100×100100 \times 100, получит срез 50×5050 \times 50 и завершит работу с критической ошибкой.

Для защиты от деформации размерностей в промышленном коде применяется принудительное ограничение координат (clipping) с последующим дополнением недостающих пикселей (padding):

def safe_crop(image: np.ndarray, x: int, y: int, w: int, h: int) -> np.ndarray:
    """Безопасное кадрирование с сохранением целевого размера через паддинг."""
    img_h, img_w = image.shape[:2]

    # Ограничиваем координаты границами матрицы
    x1 = max(0, min(x, img_w))
    y1 = max(0, min(y, img_h))
    x2 = max(0, min(x + w, img_w))
    y2 = max(0, min(y + h, img_h))

    # Извлекаем доступный фрагмент через копию
    cropped = image[y1:y2, x1:x2].copy()

    # Если фрагмент меньше целевого размера, создаем пустой холст и вставляем фрагмент
    if cropped.shape[0] != h or cropped.shape[1] != w:
        result = np.zeros((h, w, image.shape[2]), dtype=image.dtype)
        # Вычисляем смещение для вставки внутри целевого окна
        target_y = max(0, -y)
        target_x = max(0, -x)
        result[target_y : target_y + cropped.shape[0], target_x : target_x + cropped.shape[1]] = cropped
        return result

    return cropped

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

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

Арифметика пикселей: базовое управление яркостью, контрастом и цветовыми каналами

Арифметика пикселей: базовое управление яркостью, контрастом и цветовыми каналами

Если прибавить число 50 к каждому пикселю фотографии, сделанной в сумерках, изображение не просто станет светлее: участки чистого белого неба могут внезапно превратиться в черные или фиолетовые пятна. Цифровое изображение — это числовая матрица, но наивная математика над ней часто приводит к визуальным артефактам из-за особенностей машинного представления чисел.

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

Насыщение против переполнения: два подхода к вычислениям

При работе с 8-битными матрицами стандартные операторы Python и библиотеки NumPy выполняют вычисления по модулю 256. Если значение пикселя равно 240, а мы прибавляем 30, результат 270270 не помещается в один байт: происходит сброс старшего бита, и в матрицу записывается 1414. В результате самая яркая область кадра становится практически черной.

В компьютерном зрении для визуальных преобразований используется другой математический аппарат — насыщающая арифметика (saturation arithmetic). При выходе за допустимый диапазон значение не циклически сбрасывается, а «прилипает» к ближайшей границе:

fsat(x)=min(max(x,0),255)f_{\text{sat}}(x) = \min(\max(x, 0), 255)

Здесь xx обозначает результат промежуточного математического вычисления, а значение функции fsat(x)f_{\text{sat}}(x) гарантированно лежит в диапазоне от 0 до 255. Если в результате сложения получается 270270, на выходе фиксируется 255255. Если при вычитании получается 20-20, фиксируется 00.

Библиотека OpenCV по умолчанию реализует насыщающую арифметику во всех базовых функциях сложения и вычитания.

import cv2
import numpy as np

# Создаем два тестовых пикселя со значениями близкими к границам
px1 = np.uint8([250])
px2 = np.uint8([20])

# Сложение через оператор NumPy (арифметика по модулю 256)
res_numpy = px1 + px2  # 250 + 20 = 270 % 256 = 14

# Сложение через специализированную функцию OpenCV (насыщение)
res_cv2 = cv2.add(px1, px2)  # min(250 + 20, 255) = 255

Аналогично работает вычитание: функция cv2.subtract(px2, px1) выдаст 00, тогда как выражение px2 - px1 в NumPy вернет 226226 из-за циклического переноса беззнакового типа.

Линейная модель яркости и контраста

Фундаментальный способ коррекции освещенности и выразительности деталей изображения — применение поэлементного линейного преобразования:

g(y,x)=αf(y,x)+βg(y, x) = \alpha \cdot f(y, x) + \beta

В этой формуле:

  • f(y,x)f(y, x) — исходное значение интенсивности пикселя в строке yy и столбце xx.
  • g(y,x)g(y, x) — новое значение того же пикселя после преобразования.
  • α\alpha (коэффициент масштабирования, α>0\alpha > 0) отвечает за контраст. Значения α>1\alpha > 1 растягивают динамический диапазон (разница между темными и светлыми тонами увеличивается), а α<1\alpha < 1 сжимают его к средним значениям, делая картинку блеклой.
  • β\beta (смещение, β[255,255]\beta \in [-255, 255]) отвечает за яркость. Положительное смещение равномерно сдвигает все уровни квантования вверх, осветляя кадр, отрицательное — сдвигает вниз.

Если исходный пиксель имел яркость 100100, при α=1.2\alpha = 1.2 и β=30\beta = 30 новое значение составит:

1.2×100+30=1501.2 \times 100 + 30 = 150

Для реализации этой формулы в коде есть два пути: явное приведение к числам с плавающей точкой через NumPy или оптимизированная функция cv2.convertScaleAbs.

Реализация через преобразование типов NumPy

Классический пайплайн обработки требует временного перехода в float32, чтобы избежать потери точности дробной части коэффициента α\alpha и предотвратить переполнение на промежуточных шагах:

def adjust_brightness_contrast_numpy(image, alpha=1.0, beta=0):
    # 1. Переводим матрицу во float32 для безопасных вычислений
    img_float = image.astype(np.float32)

    # 2. Применяем линейное масштабирование
    adjusted = alpha * img_float + beta

    # 3. Ограничиваем диапазон [0, 255] и возвращаем исходный тип uint8
    clipped = np.clip(adjusted, 0, 255)
    return clipped.astype(np.uint8)

Высокопроизводительный метод OpenCV

В реальных пайплайнах видеоаналитики создание промежуточного массива float32 вчетверо увеличивает объем занимаемой оперативной памяти для каждого кадра. OpenCV предоставляет функцию cv2.convertScaleAbs, которая объединяет линейное масштабирование, вычисление абсолютного значения, насыщение до диапазона [0,255][0, 255] и приведение к uint8 внутри одного оптимизированного C++ цикла:

# Эквивалентно: saturate_cast<uchar>(|alpha * image + beta|)
result = cv2.convertScaleAbs(image, alpha=1.3, beta=20)

Разделение, модификация и слияние цветовых каналов

Цветное цифровое изображение состоит из нескольких независимых матриц (в стандарте OpenCV это плоскости Blue, Green, Red). Применение арифметических операций не обязательно должно быть глобальным: выборочная коррекция отдельных каналов позволяет устранять паразитные цветовые оттенки (цветовой баланс) или изолировать спектральные признаки объектов.

Для изоляции каналов используются два подхода: срезы NumPy и встроенные функции OpenCV.

Метод Механизм памяти Скорость Синтаксис
Срезы NumPy Создает View (представление) без копирования Мгновенно (O(1)O(1)) b = img[:, :, 0]
cv2.split Выделяет память и копирует 3 массива Затраты на O(N)O(N) копирование b, g, r = cv2.split(img)
# Быстрое извлечение каналов через срезы NumPy
blue_channel = image[:, :, 0]
green_channel = image[:, :, 1]
red_channel = image[:, :, 2]

# Усиление теплого оттенка: увеличиваем красный, ослабляем синий
red_boosted = cv2.add(red_channel, 30)
blue_reduced = cv2.subtract(blue_channel, 20)

# Сборка модифицированных каналов обратно в трехканальный BGR-массив
processed_image = cv2.merge([blue_reduced, green_channel, red_boosted])

Обратите внимание: cv2.merge принимает список или кортеж из одноканальных матриц одинакового размера и склеивает их вдоль третьей оси, создавая непрерывный массив формы (H,W,3)(H, W, 3).

Линейное смешивание изображений (Alpha Blending)

Частая задача первичной обработки — наложение полупрозрачной маски, плавный переход между кадрами видеопотока или объединение нескольких спектральных диапазонов (например, видимого и инфракрасного света).

Математически смешивание двух матриц одинакового размера f1(y,x)f_1(y, x) и f2(y,x)f_2(y, x) выражается через взвешенную сумму:

g(y,x)=αf1(y,x)+βf2(y,x)+γg(y, x) = \alpha \cdot f_1(y, x) + \beta \cdot f_2(y, x) + \gamma

Здесь веса α\alpha и β\beta определяют прозрачность каждого слоя (обычно выбираются так, чтобы α+β=1.0\alpha + \beta = 1.0), а γ\gamma служит скалярным смещением итоговой яркости.

# Загрузка двух изображений одинакового разрешения
frame_vis = cv2.imread('visible.jpg')
frame_ir = cv2.imread('infrared.jpg')

# Взвешенное наложение: 70% видимого спектра + 30% ИК-спектра
blended = cv2.addWeighted(
    src1=frame_vis,
    alpha=0.7,
    src2=frame_ir,
    beta=0.3,
    gamma=0
)

Функция cv2.addWeighted выполняет насыщение результата до диапазона [0,255][0, 255], что исключает появление артефактов даже при сумме весов, превышающей 1.01.0.

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

Практикум: создание скрипта первичной подготовки и очистки изображений

Практикум: создание скрипта первичной подготовки и очистки изображений

В реальных системах компьютерного зрения до 80% сбоев происходят задолго до запуска нейросетей — на этапе получения «грязных» входных данных. Поврежденные при передаче файлы нулевого размера, случайные кадры с перевернутыми цветовыми каналами, засвеченные области и координаты объектов, выходящие за физические границы матрицы, способны аварийно остановить весь сервис или незаметно исказить результаты обучения модели.

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

Архитектура конвейера предобработки

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

Конвейер состоит из пяти последовательных стадий:

  1. Сканирование и обнаружение (Ingestion): обход файловой системы, поиск целевых форматов (JPEG, PNG) и построение очереди обработки.
  2. Защитная валидация (Validation): проверка целостности заголовков, успешности декодирования в массив ndarray, соответствия размерностей и канальности.
  3. Геометрическая стандартизация (Spatial Transform): безопасное выделение области интереса (ROI) по координатам с контролем краевых эффектов.
  4. Фотометрическая коррекция (Radiometric Transform): выравнивание контраста, коррекция экспозиции и приведение цветового пространства к целевому стандарту.
  5. Сериализация и аудит (Export & Logging): сохранение обработанных матриц с контролем сжатия и формирование итогового отчета о пропущенных и обработанных кадрах.

Защитная валидация при пакетной загрузке

Главное правило пакетной обработки — скрипт никогда не должен прерывать выполнение из-за единичного некорректного файла. При обработке папки с тысячами изображений могут встретиться файлы с нулевым размером, текстовые файлы с расширением .jpg или битые бинарные структуры.

Использование стандартной библиотеки pathlib в связке с защитными проверками OpenCV позволяет изолировать проблемные файлы:

from pathlib import Path
import cv2

def load_and_validate_image(file_path: Path):
    """
    Загружает изображение и проверяет его валидность.
    Возвращает ndarray при успехе или None при любой ошибке.
    """
    if not file_path.is_file() or file_path.stat().st_size == 0:
        return None

    # Попытка декодирования бинарного файла в BGR-массив
    image = cv2.imread(str(file_path))

    # Проверка: удалось ли распаковать пиксели
    if image is None:
        return None

    # Проверка размерностей: изображение не должно иметь нулевую высоту или ширину
    if image.size == 0 or len(image.shape) != 3:
        return None

    return image

Проверка image.size == 0 гарантирует, что в памяти находится не пустая структура, а len(image.shape) == 3 отсекает непредвиденные одноканальные маски, если алгоритм рассчитан на полноцветные данные.

Модуль геометрической и фотометрической очистки

После того как кадр успешно загружен в память в виде массива uint8, к нему последовательно применяются пространственные и яркостные преобразования.

1. Безопасное кадрирование ROI

Если координаты ограничивающего прямоугольника заданы как (xmin,ymin,xmax,ymax)(x_{\min}, y_{\min}, x_{\max}, y_{\max}), они могут выходить за пределы матрицы (например, xmax>Wx_{\max} > W или ymin<0y_{\min} < 0). Чтобы избежать неявного усечения среза до некорректной формы, координаты принудительно ограничиваются границами кадра:

import numpy as np

def safe_crop_roi(image: np.ndarray, bbox: tuple) -> np.ndarray:
    """
    Вырезает область интереса с гарантией непревышения границ матрицы.
    bbox: (xmin, ymin, xmax, ymax)
    """
    h, w = image.shape[:2]
    xmin, ymin, xmax, ymax = bbox

    # Ограничение координат пределами матрицы
    x1 = max(0, min(int(xmin), w))
    y1 = max(0, min(int(ymin), h))
    x2 = max(0, min(int(xmax), w))
    y2 = max(0, min(int(ymax), h))

    # Проверка на вырожденный прямоугольник
    if x2 <= x1 or y2 <= y1:
        return np.empty((0, 0, image.shape[2]), dtype=image.dtype)

    # Возвращаем независимую копию, разрывая связь с исходным буфером
    return image[y1:y2, x1:x2].copy()

Вызов .copy() критически важен: он создает непрерывный в памяти независимый массив и предотвращает утечки памяти, когда маленький срез удерживает в ОЗУ весь исходный многомегабайтный кадр.

2. Нормализация яркости и контраста

Для устранения недоэкспонированности применяется линейное масштабирование диапазона значений с использованием насыщающей арифметики cv2.convertScaleAbs:

Iout(y,x)=clip0255(αIin(y,x)+β)I_{\text{out}}(y, x) = \operatorname{clip}_{0}^{255}(\alpha \cdot I_{\text{in}}(y, x) + \beta)

Где:

  • Iin(y,x)I_{\text{in}}(y, x) — исходное значение яркости пикселя;
  • α\alpha — коэффициент контраста (α>1\alpha > 1 увеличивает динамический диапазон между темными и светлыми точками);
  • β\beta — аддитивная добавка яркости, сдвигающая весь гистограммный профиль;
  • clip0255\operatorname{clip}_{0}^{255} — функция отсечения, фиксирующая значения в диапазоне от 0 до 255.

Например, при α=1.2\alpha = 1.2 и β=15\beta = 15 пиксель со значением 100 превращается в:

1.2100+15=1351.2 \cdot 100 + 15 = 135

А пиксель со значением 220 безопасно ограничивается верхним порогом:

1.2220+15=2792551.2 \cdot 220 + 15 = 279 \rightarrow 255

Сборка законченного скрипта предобработки

Объединим все изолированные модули в законченный скрипт пакетной очистки датасета. Скрипт принимает путь к исходной директории, применяет трансформации к каждому валидному кадру, сохраняет оптимизированный результат в целевую папку и выводит статистику работы.

import os
from pathlib import Path
import cv2
import numpy as np

def process_dataset(
    input_dir: str,
    output_dir: str,
    crop_box: tuple = None,
    alpha: float = 1.0,
    beta: float = 0.0,
    target_quality: int = 90
) -> dict:
    """
    Пакетный пайплайн очистки и стандартизации изображений.
    """
    in_path = Path(input_dir)
    out_path = Path(output_dir)
    out_path.mkdir(parents=True, exist_ok=True)

    # Поддерживаемые расширения
    valid_extensions = {".jpg", ".jpeg", ".png", ".bmp"}

    stats = {
        "total_found": 0,
        "successfully_processed": 0,
        "corrupted_or_invalid": 0,
        "failed_geometry": 0
    }

    # Поиск всех файлов в директории
    image_files = [f for f in in_path.iterdir() if f.suffix.lower() in valid_extensions]
    stats["total_found"] = len(image_files)

    for file_path in image_files:
        # 1. Загрузка и валидация
        image = load_and_validate_image(file_path)
        if image is None:
            stats["corrupted_or_invalid"] += 1
            continue

        # 2. Геометрическое кадрирование (если задано)
        if crop_box is not None:
            processed = safe_crop_roi(image, crop_box)
            if processed.size == 0:
                stats["failed_geometry"] += 1
                continue
        else:
            processed = image.copy()

        # 3. Фотометрическая коррекция (контраст и яркость)
        if alpha != 1.0 or beta != 0.0:
            processed = cv2.convertScaleAbs(processed, alpha=alpha, beta=beta)

        # 4. Сохранение с оптимизацией
        out_file = out_path / f"clean_{file_path.stem}.jpg"
        save_success = cv2.imwrite(
            str(out_file),
            processed,
            [cv2.IMWRITE_JPEG_QUALITY, target_quality]
        )

        if save_success:
            stats["successfully_processed"] += 1
        else:
            stats["corrupted_or_invalid"] += 1

    return stats

# Пример запуска конвейера:
# target_box: xmin=50, ymin=50, xmax=500, ymax=400
# results = process_dataset(
#     input_dir="./raw_dataset",
#     output_dir="./clean_dataset",
#     crop_box=(50, 50, 500, 400),
#     alpha=1.15,
#     beta=10,
#     target_quality=92
# )
# print(f"Обработка завершена: {results}")

Анализ эффективности и метрики конвейера

Применение подобного пайплайна перед обучением моделей решает три ключевые инженерные задачи:

Параметр конвейера Без предобработки С использованием пайплайна
Отказоустойчивость Аварийная остановка (Crash) при встрече битого файла 100% изоляция сбоев, сбор логов невалидных файлов
Утечки памяти Удержание родительских матриц через неявные срезы (Views) Жесткое копирование .copy() только целевых областей
Стабильность данных Случайные артефакты от переполнения uint8 Безопасная насыщающая коррекция через convertScaleAbs

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