TBitmap.ScanLine: Полное руководство

При работе с графикой в Delphi часто возникает необходимость обрабатывать изображения попиксельно — применять фильтры, конвертировать цвета, анализировать содержимое. Стандартное свойство канвы Pixels[X,Y] решает эту задачу, но работает катастрофически медленно. Свойство ScanLine предоставляет прямой доступ к памяти изображения и ускоряет обработку в десятки и сотни раз.

Статья задумывалась как прямое продолжение TBitmap.PixelFormat, но была не закончена в своё время. Поэтому их следует воспринимать как один материал. Внутри будет много отсылок к ней.

Содержание скрыть

Классический ScanLine

Синтаксис ScanLine

Свойство возвращает указатель на начало строки пикселей с номером Row. Нумерация начинается с нуля. Получив указатель, мы работаем напрямую с памятью без посредников.

Что такое строка пикселей?

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

ScanLine[Row] возвращает адрес первого пикселя в строке Row. Дальше мы можем двигаться по строке, увеличивая указатель.

Проблема нетипизированного указателя

Свойство возвращает Pointer — нетипизированный указатель. Компилятор не знает, какого размера пиксели в изображении и какую структуру они имеют. Один пиксель может занимать:

  • 1 бит (pf1bit) — 8 пикселей в байте, 2 цвета из палитры
  • 4 бита (pf4bit) — 2 пикселя в байте, до 16 цветов из палитры
  • 1 байт (pf8bit) — до 256 цветов из палитры
  • 2 байта (pf15bit, pf16bit) — цвет закодирован непосредственно в битах
  • 3 байта (pf24bit) — по байту на канал R, G, B
  • 4 байта (pf32bit) — R, G, B плюс альфа-канал

Если мы попытаемся работать с данными, не зная их формат, результат будет непредсказуемым:

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

Решение: явное указание формата

Чтобы точно знать, с какими данными работаем, перед использованием ScanLine всегда устанавливаем свойство PixelFormat:

После установки PixelFormat изображение конвертируется в указанный формат (если это необходимо), и мы можем быть уверены в структуре данных.

Принцип работы со ScanLine

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

Существует два основных шаблона работы со строкой пикселей. В первом мы получаем указатель на начало строки и последовательно инкрементируем его. Во втором мы воспринимаем строку пикселей, как массив.

Шаблон со смещением указателя

Шаблон с массивом

Какой шаблон выбрать

Первый шаблон со смещением указателя предпочтительнее по нескольким причинам:

Производительность. Операция Inc(Row) — это простое прибавление константы к адресу. Обращение Row[X] требует умножения индекса на размер элемента и сложения с базовым адресом при каждом обращении к пикселю.

Чистота кода. Не нужно объявлять дополнительные типы TRGBTripleArray и PRGBTripleArray. Указатель на одиночную запись уже объявлен в системных модулях.

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

Форматы пикселей

Все форматы пикселей рассмотрены в статье TBitmap.PixelFormat. На практике работа ведётся в основном с четырьмя форматами:

ФорматБит на пиксельПрименение
pf1bit1Чёрно-белые изображения, маски
pf8bit8Изображения с палитрой до 256 цветов
pf24bit24Полноцветные изображения без прозрачности
pf32bit32Полноцветные изображения с альфа-каналом

Каждый формат требует своего типа указателя и своего подхода к обработке. Примеры работы с каждым из них рассмотрим далее.

Формат pf24bit: Преобразование в оттенки серого

Это самый простой и распространённый формат для обработки изображений. Каждый пиксель занимает ровно 3 байта — по одному на каждый цветовой канал. Никаких палитр, никакой упаковки битов, никакого альфа-канала. Один пиксель — одна структура.

Видим, что порядок полей — BGR, а не RGB. Это наследие формата BMP, ибо Windows исторически хранит цвета именно так.

Продублирую гифку из описания pf24bit.

Преобразование в оттенки серого — это классическая задача обработки изображений. Для каждого пикселя вычисляем яркость по формуле, учитывающей особенности человеческого зрения (критика формулы), и записываем её во все три канала:

Формат pf32bit: Прозрачность на основе яркости

Этот формат расширяет pf24bit одним дополнительным байтом — альфа-каналом. Каждый пиксель занимает 4 байта, что удобно для выравнивания в памяти и делает доступ чуть быстрее. Альфа-канал управляет прозрачностью: 0 — полностью прозрачный, 255 — полностью непрозрачный.

Порядок цветовых каналов тот же — BGR. Поле rgbReserved исторически называлось «зарезервированным», но сегодня повсеместно используется как альфа-канал.

Тип TRGBQuad объявлен в модуле Winapi.Windows.

Подробное описание формата pf32bit.

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

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

Также, здесь используется Move вместо покомпонентного копирования. Вместо трёх присваиваний:

одна операция копирует все 4 байта разом, а затем перезаписывается только альфа-канал. Короче и быстрее.

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

  • Тёмные пиксели (яркость близка к 0) — почти прозрачные
  • Светлые пиксели (яркость близка к 255) — почти непрозрачные
  • Промежуточные оттенки — частично прозрачные

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

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

Формат pf8bit: Grayscale-палитра и постеризация

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

Такой подход экономит память: 1 байт на пиксель вместо 3-4. Но ограничивает изображение 256 цветами.

Доступ к пикселям осуществляется очень просто — это указатель на байт:

Подробно про pf8bit.

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

Пример 1: Grayscale-палитра. Конвертируем цветное изображение в 8-битное с палитрой из 256 оттенков серого. Вычисляем яркость и записываем её как индекс — индекс 128 соответствует цвету (128, 128, 128) в палитре.

Результат визуально идентичен 24-битному grayscale из первого раздела — те же 256 градаций яркости. Но размер данных втрое меньше: 1 байт на пиксель вместо 3. Для изображения 1000×1000 это приблизительно 1 МБ (≈ 0.95 МБ, помним, что в километре у нас 1024 метра) вместо 3 МБ. Плюс 1 КБ на палитру — это пренебрежимо мало.

Именно так работает большинство программ при сохранении grayscale-изображений — используют 8-битный формат с серой палитрой.

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

При Levels = 8 получаем изображение с резкими переходами: чёрный, тёмно-серый, серый и так далее до белого. Чем меньше уровней, тем грубее и «плакатнее» результат.

Формат pf1bit: Монохромное изображение

Самый компактный формат — один бит на пиксель, всего два цвета. Восемь пикселей упакованы в один байт, что делает доступ чуть сложнее — нужны битовые операции.

Как и в pf8bit, цвета определяются палитрой — просто в ней всего две записи. Обычно это чёрный и белый, но можно задать любые два цвета.

Внимание: старший бит (7) соответствует левому пикселю в байте, младший (0) — правому.

Смотрим описание формата pf1bit.

Пример: пороговое преобразование. Классическое преобразование в чёрно-белое: пиксели с яркостью выше порога становятся белыми, остальные — чёрными.

Выбор порога:

  • 128 — стандартный средний порог, делит диапазон пополам
  • Ниже 128 — больше белого, изображение светлее
  • Выше 128 — больше чёрного, изображение темнее

Для документов с текстом обычно подбирают порог экспериментально — где-то между 100 и 180 в зависимости от качества скана.

Изображение в формате pf1bit занимает в 24 раза меньше, чем pf24bit. Именно этот формат используется для факсов, сканов документов и штрих-кодов.

Быстрый ScanLine

В предыдущих примерах мы вызывали ScanLine[Y] для каждой строки. Это свойство не просто возвращает указатель — оно выполняет проверки и вычисления при каждом обращении. Этого можно избежать и существенно ускорить обработку больших битмапов.

Немного теории

DIB-секция (а TBitmap по умолчанию хранит данные именно так) держит пиксели в непрерывном блоке памяти. Строки идут снизу вверх (bottom-up) с выравниванием каждой строки до границы 4 байт (DWORD-aligned). Изменить порядок для VCL.TBitmap возможности нет, поэтому считаем, что это всегда так.

Поэтому ScanLine[Height-1] — это указатель на самый младший адрес массива (первая строка в памяти = последняя строка изображения). Таким образом, можно получить указатель на начало один раз и дальше перемещаться арифметикой указателей.

Для битмапа формата pf32bit (и только для него) применим такой базовый шаблон:

Как ранее говорилось, размер строки пикселей выравнивается по границе 4 байт. Из-за выравнивания в 4 байта в конце каждой строки могут быть padding-байты. Если гнать линейно через Width * Height — указатель съедет и мы начнём читать мусор из padding’а как пиксели. Для pf32bit это работает (4 байта на пиксель, строка всегда кратна 4), но для pf24bit, pf8bit, pf1bit — катастрофа.

На рисунке массив байт битмапа 31×32 формата pf24bit. При ширине в 31 пиксел, в конце каждой строки есть три неиспользуемых байта, которые присутствуют, чтобы добить длину строки до количества, кратного 4. Поэтому, при перемещении по непрерывному массиву байт надо их учитывать.

Для вычисления длины строки с учётом выравнивания есть штатная функция BytesPerScanline.

Примерный шаблон для работы с битмапом формата pf24bit:

Это шаблон с «перешагиванием» зоны незначащих выравнивающих байт с целью выйти на начало следующей строки. Это далеко не единственный способ работать с массивом пикселей битмапа. В следующих примерах будут показаны разные способы и прокомментированы различия.

Быстрый Grayscale (pf24bit)

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

Этот код не вычисляет padding вообще. Вместо этого он использует два уровня указателей:

  • SrcPtrStartDstPtrStart — указатели на начало текущей строки, сдвигаются на полную BytesPerScanline после каждой строки
  • SrcPtrDstPtr — рабочие указатели, каждую строку сбрасываются на PtrStart и бегут по пикселям

Рабочие указатели ничего не знают о padding — они честно проходят Width пикселей и выбрасываются. А PtrStart прыгает ровно на BytesPerScanline, автоматически перешагивая и данные, и padding одним действием.

По сути, это эквивалент классического ScanLine[Y] в цикле, только адрес следующей строки вычисляется сложением, а не через свойство. Это в любом случае быстрее классического варианта, потому что ScanLine при каждом вызове делает ряд проверок, а здесь — одно сложение указателя с константой.

Оба подхода корректны. Шаблон чуть экономнее на переменных. Этот вариант чуть нагляднее — видно разделение на «где мы стоим» и «куда мы бежим».

Быстрый Alpha by Brightness (pf32bit)

Для pf32bit выравнивание не играет роли — 4 байта на пиксель всегда кратны 4. Можно пройти весь блок линейно:

Ключевое отличие от шаблона состоит в том, здесь полностью линейный проход. Для pf32bit padding невозможен — 4 байта на пиксель всегда кратны границе выравнивания 4 байта. Поэтому двойной цикл Y, X не нужен. Один цикл по Total, никаких вычислений padding, никаких BytesPerScanline. Самый чистый случай быстрого Scanline.

Быстрый Grayscale 8-bit (pf8bit)

Ещё один вариант навигации по строкам — через вычисление адреса.

Начальные адреса хранятся как NativeInt, а указатель на начало каждой строки вычисляется прямым умножением:

Padding не вычисляется — он автоматически учтён внутри BytesPerScanline. Рабочие указатели SrcPtr и DstPtr не переживают итерацию внешнего цикла — каждую строку пересоздаются из базового адреса.

Сравнение трёх подходов:

  • Шаблон с padding — идём указателем, в конце строки перешагиваем padding
  • Два уровня указателей — PtrStart шагает на BytesPerScanline, рабочий указатель переназначается с каждой новой строкой
  • Этот вариант — адрес строки вычисляется каждый раз через Base + Y * Stride

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

Так и просится пример: для зеркального отражения по вертикали такой подход с произвольной адресацией строк очень удобен — читаем строку Y, пишем в строку Height — 1 — Y:

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

Быстрая постеризация (pf8bit)

Этот код — чистое применение базового шаблона без каких-либо вариаций. Вычисляем padding, встаём на начало блока, двойной цикл, перешагиваем padding в конце строки. Вся специфика постеризации — только в одной строке внутри цикла:

Быстрый Threshold (pf1bit)

Формат pf1bit ломает шаблон в двух местах.

Нет Inc(DstPtr) во внутреннем цикле. В шаблоне оба указателя синхронно шагают по пикселям. Здесь источник идёт по PRGBTriple как обычно, а приёмник — нет. Один байт приёмника содержит 8 пикселей, поэтому вместо инкремента указателя — адресация через DstRow[ByteIndex] с битовыми операциями.

Нет DstPadding. В шаблоне приёмник перешагивает padding после каждой строки. Здесь рабочий указатель DstRow не двигается во внутреннем цикле вообще, поэтому перешагивать нечего — сразу шагаем на полную DstBytesPerLine:

По сути DstRow ведёт себя как PtrStart из варианта с двумя уровнями указателей — указывает на начало строки, а доступ внутри строки — через индекс.

Итоговые выводы

Быстрый Scanline — не отдельная техника, а простая идея: получить начальный адрес данных один раз и дальше перемещаться арифметикой указателей, не обращаясь к свойству ScanLine[Y] повторно.

Мы разобрали несколько вариантов реализации этой идеи:

  • Padding — вычисляем неиспользуемые байты в конце строки и перешагиваем их
  • Два уровня указателей — PtrStart шагает на BytesPerScanline, рабочий указатель сбрасывается каждую строку
  • Вычисление адреса — Base + Y * Stride, допускает произвольный порядок обхода строк
  • Линейный проход — для pf32bit padding невозможен, двойной цикл не нужен

Все подходы корректны. Выбор между ними — дело вкуса и конкретной задачи. Общее одно: обращение к ScanLine происходит один раз при инициализации, а не на каждой строке.

Бенчмарк: Классический vs Быстрый

Абсолютные значения зависят от процессора, размера изображения и фоновой нагрузки. Диаграмма показывает соотношение, а не конкретные миллисекунды. Бенчмарк встроен в демо-приложение — загрузите своё изображение и проверьте сами.

Диаграмма подтверждает тезис статьи: быстрый ScanLine стабильно опережает классический по всем форматам.

Alpha 32 — самый быстрый из всех. Формат pf32bit идеально выровнен: 4 байта на пиксель, строка всегда кратна 4, padding отсутствует. Процессор работает с выровненными данными без штрафов. При этом разница Classic/Fast здесь тоже заметна — накладные расходы на вызов ScanLine[Y] никуда не деваются.

Mono 1-bit — самый медленный. Это ожидаемо: битовая адресация, упаковка 8 пикселей в байт, операции сдвига и маскирования на каждый пиксель.

Тяжесть вычислений внутри цикла. Если на каждый пиксель приходится Round() с тремя умножениями на Double, то время вызова ScanLine[Y] растворяется на фоне вычислений. Быстрый вариант всё равно быстрее, но разница скромнее.

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

Кэшированный ScanLine

Быстрый Scanline хорош для последовательного прохода — сверху вниз (последовательно по непрерывному массиву), строка за строкой. Но есть задачи, где доступ к строкам непоследователен и непредсказуем:

  • Фильтры свёртки — для каждого пикселя нужны соседние строки: текущая, верхняя, нижняя;
  • Масштабирование — строка-источник вычисляется по коэффициенту, порядок произвольный;
  • Повороты на произвольный угол — координаты источника определяются тригонометрией;
  • Алгоритмы диффузии ошибок — ошибка квантования распространяется на соседние строки.

В таких случаях быстрый Scanline с последовательным Inc() не подходит — мы прыгаем по строкам хаотично. Вычисление адреса через Base + Y * Stride работает, но при многократном обращении к одной и той же строке мы каждый раз выполняем умножение заново.

Идея кэшированного ScanLine проста: завести массив указателей размером с высоту изображения, изначально заполненный nil. При первом обращении к строке — вызвать ScanLine[Y] и сохранить результат. При повторном — вернуть сохранённый указатель без каких-либо вычислений:

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

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

Пример

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

Кэш оправдан там, где одна строка запрашивается многократно. Нужны примеры, где это происходит естественно:

  • Фильтр свёртки (размытие, резкость) — для каждого пикселя читаем 3, 5 или более строк, и соседние пиксели делят большинство этих строк;
  • Масштабирование методом ближайшего соседа — несколько строк результата могут ссылаться на одну строку источника;
  • Диффузия ошибок (Флойд — Стейнберг) — ошибка распространяется на текущую и следующую строку, обе нужны одновременно.

Поэтому возьмём в качестве примера какой-нибудь эффект, где происходит непоследовательное обращение к строкам битмапа. Например, эффект стекла.

Эффект стекла: Базовая версия

Источник читается последовательно — Bitmap.ScanLine[Y] вызывается один раз на строку во внешнем цикле, это классический ScanLine.

Проблема — в записи результата. DstY вычисляется случайно, поэтому Result.ScanLine[DstY] вызывается внутри двойного цикла для каждого пикселя. Для изображения 1000×1000 — до миллиона вызовов свойства, при том что уникальных строк всего тысяча. Именно эту проблему решает кэшированная версия.

Эффект стекла: Версия с кэшем

Ленивый кэш — строка запрашивается у VCL.Bitmap только при первом попадании в неё. Повторные обращения — чтение указателя из массива. Источник идёт последовательно, кэшировать его незачем.

Комментарии по коду

Формула яркости повторяется 10 раз в идентичном виде. Формула повторяется намеренно — каждый пример компилируется самостоятельно. Никуда не надо смотреть дополнительно, каждая процедура самодостаточна.

Value.AsType<Byte> — используется в постеризации и монохроме, но не в grayscale и alpha. Это следствие архитектуры демо-приложения: все процедуры имеют единую сигнатуру для унификации вызовов. Процедуры, которым параметр не нужен, просто его игнорируют.

Возможные ошибки

Не установлен PixelFormat

Самая частая ошибка. Без явной установки формат может оказаться любым — pfDevice, pf16bit, что угодно. Код будет компилироваться, но данные окажутся не той структуры, которую ожидает указатель.

Несоответствие типа указателя и формата

Установили pf32bit, а работаем через PRGBTriple. Или наоборот. Указатель шагает не с тем размером — каждый Inc смещается на 3 байта вместо 4 (или наоборот), данные «плывут» с накоплением ошибки от пикселя к пикселю.

Обращение к Canvas между вызовами ScanLine

ScanLine работает с DIB-данными напрямую. Обращение к Canvas (рисование, вызов Canvas.Handle) может вызвать пересоздание внутреннего GDI-объекта, и ранее полученные указатели станут невалидными.

Правило: получил ScanLine — работай только с ним. Всё рисование через Canvas — до или после.

Игнорирование padding в быстром ScanLine

Для pf32bit padding невозможен. Для всех остальных форматов — возможен. Линейный проход через Width * Height по pf24bit изображению со строкой не кратной 4 байтам приведёт к смещению указателя в область выравнивающих байтов.

Забыли про bottom-up порядок строк

ScanLine[0] — верхняя строка изображения, но в памяти она расположена по старшему адресу. ScanLine[Height-1] — самый младший адрес. При быстром ScanLine мы стартуем с ScanLine[Height-1] и идём по адресам в сторону увеличения. Если перепутать, будет нарушение доступа или другая малоприятная фигня.

Integer вместо NativeInt для адресной арифметики

В 32-битных приложениях Integer и указатель одного размера — 4 байта. В 64-битных указатель — 8 байт, а Integer по-прежнему 4. Адрес обрежется, программа упадёт.

Заключение

Мы рассмотрели три способа работы с пикселями через ScanLine:

Классический ScanLine — вызов свойства ScanLine[Y] на каждой строке. Просто, надёжно, достаточно для большинства задач. Главное ограничение — обращение к свойству внутри цикла имеет свою цену.

Быстрый ScanLine — получаем начальный адрес данных один раз и дальше перемещаемся арифметикой указателей. Несколько вариантов реализации — через padding, два уровня указателей, вычисление адреса, линейный проход — но идея одна: не обращаться к свойству ScanLine повторно.

Кэшированный ScanLine — массив указателей на строки, заполняемый лениво при первом обращении. Оправдан там, где доступ к строкам непоследователен и одна строка запрашивается многократно.

Какой способ выбрать — зависит от задачи:

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

Все три подхода объединяет одно правило: PixelFormat нужно установить явно до первого обращения к ScanLine. Без этого — непредсказуемый формат данных и неопределённое поведение.


Скачать

Друзья, спасибо за внимание!

PixelFormat+ScanLine: тут дополненная демонстрацией массива байт демка для форматов

Исходник (zip) 271 Кб. Delphi XE 7

Исполняемый файл (zip) 1.12 Мб.

ScanLineDemo: тут описанные эффекты и бенчмарк

Исходник (zip) 846 Кб. Delphi XE 7, XE 13

Исполняемый файл (zip) 1.85 Мб.


5 3 голоса
Рейтинг статьи
Подписаться
Уведомить о
guest

0 комментариев
Старые
Новые Популярные
Межтекстовые Отзывы
Посмотреть все комментарии