5.1. Объект Image¶
Алгоритм обработки изображений проходит по изображению по одному пикселю за раз. В каждой позиции он делает что-то простое – считывает значение, сравнивает его с порогом, объединяет его с соответствующим пикселем второго изображения, записывает результат обратно. Повторённые по всему кадру, эти простые решения для каждого пикселя – именно то, из чего строятся обнаружение границ, отслеживание блобов, декодирование QR-кодов и любая другая классическая методика компьютерного зрения. Чтобы выполнять эту работу эффективно, алгоритм должен знать, где каждый пиксель располагается в памяти, что на самом деле означает значение каждого пикселя и на какую часть изображения ему следует смотреть. image.Image – это объект, который организует эту информацию.
Раздел Vision Sensors заканчивался в момент, когда csi.CSI.snapshot() возвращает результат. Всё, что машинерия на стороне камеры сделала для получения захваченного кадра, уже выполнено; у приложения в руках есть Image, и ему нужно знать, что с ним делать.
5.1.1. Буфер и его свойства¶
Внутри Image находится указатель на непрерывный блок байтов в RAM и небольшой заголовок, несущий три фрагмента метаданных: ширину изображения в пикселях, его высоту в пикселях и формат пикселей, в котором представлены байты. Байты – это сами пиксели, хранящиеся в порядке по строкам (row-major) – сначала все пиксели верхней строки, затем все пиксели второй строки и так далее вниз до самой нижней. Свойства описывают, как их читать.
Ширина и высота – это просто целочисленные значения количества. Формат пикселей – более интересное свойство, потому что он задаёт, сколько байтов занимает каждый пиксель и что эти байты кодируют. Изображение в оттенках серого несёт один байт на пиксель, содержащий значение яркости. Изображение RGB565 несёт два байта на пиксель, содержащих поля красного, зелёного и синего, упакованные в 16-битное слово. Изображение Bayer несёт один байт на пиксель, но каждый пиксель снимается через один из трёх цветовых фильтров, выбранный в зависимости от его положения в мозаике. Vision Sensors перечислил весь каталог; здесь важно то, что на каждом Image установлен ровно один из этих форматов, и этот выбор определяет арифметику байтов на пиксель и смысл любого отдельного байта в буфере.
Имея указатель на буфер, ширину, высоту и формат, любое другое свойство, которое может понадобиться алгоритму, сводится к короткому вычислению. Байт, с которого начинается пиксель (x, y), располагается со смещением (y * width + x) * bytes_per_pixel от начала буфера. Общее количество байтов равно width * height * bytes_per_pixel. Адрес следующей строки ниже находится ровно через width * bytes_per_pixel байтов после начала текущей. Image предоставляет эти три свойства через обычные вызовы методов – width(), height(), format() – плюс производный size через size(). Методы в других местах модуля используют эти значения, чтобы самостоятельно выполнять арифметику смещений; коду приложения это редко приходится делать.
Image – это небольшая обёртка Python, указывающая на непрерывный блок памяти: заголовок, несущий ширину, высоту и формат пикселей, за которым следует сам буфер пикселей.¶
5.1.2. Откуда берётся буфер¶
Стандартный сценарий на протяжении всей этой главы – тот, что уже был рассмотрен в Vision Sensors: захваченный кадр приходит от snapshot, байты находятся в буфере кадра камеры, и возвращённый Image указывает на них. Регулярно встречаются ещё три способа получить его, и каждый подразумевает что-то иное относительно того, где в итоге оказывается буфер.
Загрузка из файла выглядит как передача пути конструктору: image.Image("/sdcard/saved.jpg"). Модуль читает файл в свежевыделенный буфер в куче Python. Файлы BMP, PGM и PPM декодируются при загрузке, и получившийся Image несёт несжатый формат пикселей. Файлы JPEG и PNG остаются сжатыми – Image несёт формат JPEG или PNG, а буфер содержит байтовый поток файла практически без изменений. Чтобы выполнить какую-либо работу на уровне пикселей со сжатым изображением, приложение сначала преобразует его через to_rgb565() или to_grayscale(), и именно в этом преобразовании происходит распаковка – и соответствующее раздувание кучи, когда 30 КБ JPEG может превратиться в 600 КБ RGB565. Загрузка из файла наиболее полезна во время разработки, когда алгоритм нужно протестировать на известном эталонном кадре, хранящемся рядом со скриптом.
Построение с нуля – это случай холста: image.Image(320, 240, image.RGB565) просит модуль выделить столько байтов в этом формате, обнулить содержимое и вернуть обёртку. Пиксели пока ничего не означают – они все нулевые, – но пустое изображение является рабочей лошадкой для ряда повторяющихся шаблонов: эталонные кадры, из которых вычитается текущий кадр, холсты, на которых компонуются графические наложения, бинарные буферы, которые заполняются и используются как маски.
Построение из ndarray наводит мост в другом направлении – от любого численного вычисления обратно в модуль image. Передача float32 ulab.numpy.ndarray конструктору создаёт Image, чьи размеры соответствуют ndarray – двухосная форма (h, w) становится изображением в оттенках серого, трёхосная форма (h, w, 3) становится RGB565 – с масштабированием значений с плавающей точкой из диапазона 0.0 – 255.0 в целочисленный диапазон пикселей. Тепловая карта нейронной сети, числовой массив любого вида, всё, что произведено ml или ulab, становится тем, что может использовать сторона модуля image, отвечающая за рисование и инспекцию.
Все четыре источника возвращают один и тот же вид Image. Коду, использующему возвращённый объект, никогда не нужно отслеживать, откуда он взялся.
5.1.3. Два представления над байтами¶
Большую часть времени код приложения обращается с Image как с типизированным объектом изображения – сущностью с именованными методами. Другая половина истории состоит в том, что тот же объект также прозрачно предстаёт в виде плоской последовательности байтов для любого API MicroPython, принимающего аргумент bytes. Эти байты не являются копией буфера; это его прямое представление.
Именно это устройство делает выталкивание захваченного кадра из камеры однострочником. Хеширование, отправка через последовательный порт, пересылка в сетевой сокет – ни одно из этого не нуждается в отдельном шаге «преобразовать изображение в байты»:
import csi
import hashlib
csi0 = csi.CSI()
csi0.reset()
csi0.pixformat(csi.RGB565)
csi0.framesize(csi.QQVGA)
img = csi0.snapshot()
uart.write(img) # transmits the raw pixel bytes
hashlib.sha256(img) # hashes the same bytes
sock.send(img) # sends them over a socket
Байтоподобное представление по умолчанию только для чтения, и это сделано намеренно. Буферы изображений велики и иногда совместно используются разными слоями стека обработки изображений, поэтому дать случайному buf[0] = 0 где-то глубоко в стеке вызовов возможность незаметно повредить его – это слишком острый край, чтобы оставлять его открытым. Когда приложению действительно нужен доступ на уровне байтов с чтением и записью – например, для записи калибровочного значения в известное смещение – bytearray() возвращает отдельное, явно доступное для чтения и записи представление над той же памятью, обозначая намерение в месте вызова.
5.1.4. Где находится буфер¶
Буферы пикселей достаточно велики, чтобы их расположение в RAM имело значение. Кадр QQVGA RGB565 – это 160 × 120 × 2 = 38 400 байтов; кадр VGA RGB565 – 614 400 байтов; вход 224 × 224 RGB565, который может потреблять классификатор на нейронной сети, составляет около 100 КБ. Куча Python на самых маленьких камерах после загрузки среды выполнения может составлять всего несколько десятков килобайт. Хранение более одного-двух кадров данных изображения в куче вытеснило бы из неё всё остальное.
Выход в том, что буферы изображений большей частью не находятся в куче Python. Они находятся в выделенной области RAM, которую Vision Sensors представил как буфер кадра – ту же память, в которую DMA камеры записывает захваченные кадры и из которой превью IDE считывает готовые кадры. Большинство операций над Image изменяют свой источник на месте: алгоритм читает пиксели, принимает решение, записывает новые значения обратно, и никакое отдельное результирующее изображение не выделяется. Операции, которые действительно производят отдельный результат – преобразования форматов и горстка других – можно попросить разместить этот результат в буфере кадра через именованный аргумент copy_to_fb. copy_to_fb=True делает две вещи сразу: помещает результирующее изображение в буфер кадра, а не в кучу (обходя давление на кучу), и делает результат следующим кадром, который отобразит превью IDE. Добавление copy_to_fb=True к последнему шагу конвейера, наблюдение за появлением результата на экране и итерирование на основе этого – одна из самых полезных идиом отладки в обработке изображений.
Имея обёртку, держащую размеченный буфер, четыре способа привести его в существование, два представления над его байтами и переключатель, решающий, где окажутся новые, Image больше не является загадкой. Оставшиеся фундаментальные вопросы – как именуется позиция пикселя, что на самом деле содержит каждый пиксель, как ограничить операцию частью изображения – построены поверх него.