Фаза 04 · урок 01

Основы изображений: пиксели, каналы, цветовые пространства

Цель урока: Каждая статья, которую вы прочитаете, каждый скачанный набор предобученных весов и каждый API компьютерного зрения предполагают конкретное кодирование входа. Передайте изображение uint8 , когда модели нужен float32 , — она всё равно…

Текущий релиз AlexBred.com: первые 100 уроков русскоязычной программы.

Курс
AI Engineering from Scratch
Фаза
Компьютерное зрение
Чтение
17 мин.
Проверено
Содержание урока
  1. Цели обучения
  2. Проблема
  3. Концепция
  4. Полный конвейер предобработки одним взглядом
  5. Пиксель — это измерение, а не квадрат
  6. Почему три канала
  7. Два соглашения о раскладке: HWC и CHW
  8. Диапазоны байтов и dtype
  9. Цветовые пространства и зачем они нужны
  10. Соотношение сторон, изменение размера и интерполяция
  11. Соберите это
  12. Шаг 1: Загрузите изображение и изучите его форму
  13. Шаг 2: Разделите каналы и измените порядок раскладки
  14. Шаг 3: Преобразования в оттенки серого и HSV
  15. Шаг 4: Нормализуйте, стандартизируйте и выполните обратное преобразование
  16. Шаг 5: Измените размер с тремя методами интерполяции
  17. Примените это
  18. Подготовьте к поставке
  19. Упражнения
  20. Ключевые термины
  21. Дополнительное чтение

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

Тип: Сборка Языки: Python Предварительные требования: Фаза 1, урок 12 (Операции с тензорами); фаза 3, урок 11 (Введение в PyTorch) Время: ~45 минут

Цели обучения

  • Объяснять, как непрерывная сцена дискретизируется в пиксели и почему решения о выборке и квантовании задают предел для каждой последующей модели
  • Читать, срезать и исследовать изображения как массивы NumPy, а также уверенно переключаться между раскладками HWC и CHW
  • Преобразовывать RGB, оттенки серого, HSV и YCbCr и объяснять назначение каждого цветового пространства
  • Применять предобработку на уровне пикселей (нормализация, стандартизация, изменение размера, порядок «каналы сначала») в точности так, как ожидает torchvision

Проблема

Каждая статья, которую вы прочитаете, каждый скачанный набор предобученных весов и каждый API компьютерного зрения предполагают конкретное кодирование входа. Передайте изображение uint8, когда модели нужен float32, — она всё равно запустится и молча выдаст мусор. Подайте BGR в сеть, обученную на RGB, — и точность упадёт на десять пунктов. Передайте модели вход с каналами в последней оси, когда она ждёт каналы в первой, — и первый свёрточный слой примет высоту за канал признаков. Ни одно из этих действий не вызовет ошибку. Они просто испортят метрики, а вы потратите неделю на поиски ошибки, которая скрывается в способе загрузки файла.

Свёртка несложна, когда вы знаете, по чему она скользит. Трудность в том, что «изображение» означает разные вещи для камеры, JPEG-декодера, PIL, OpenCV, torchvision и CUDA-ядра. У каждого стека собственный порядок осей, диапазон байтов и соглашение о каналах. Инженер компьютерного зрения, который не умеет держать это в голове, выпускает сломанные конвейеры.

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

Концепция

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

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

Диаграмма к уроку «Основы изображений: пиксели, каналы, цветовые пространства»

Два красный и синий блока — места, где возникают 80 % тихих сбоев: пропущенная стандартизация и неверная раскладка.

Пиксель — это измерение, а не квадрат

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

Continuous scene                 Sensor grid                     Digital image
(infinite detail)                (H x W detectors)               (H x W integers)

    ~~~~~                        +--+--+--+--+--+                 210 198 180 155 120
   ~   ~   ~                     |  |  |  |  |  |                 205 195 178 152 118
  ~ light ~      ---->           +--+--+--+--+--+     ---->       200 190 175 150 115
   ~~~~~                         |  |  |  |  |  |                 195 185 170 148 112
                                 +--+--+--+--+--+                 188 180 165 145 108

На этом шаге происходят два выбора, и они задают предел для всего последующего:

  • Пространственная выборка (spatial sampling) определяет число детекторов на градус сцены. Их недостаток делает края зубчатыми (алиасинг); избыток взрывает требования к хранилищу и вычислениям.
  • Квантование интенсивности (intensity quantization) определяет, насколько мелко напряжение разбивается на интервалы. 8 бит дают 256 уровней и являются стандартом для отображения. 10, 12 или 16 бит дают более плавные градиенты и важны для медицинской визуализации, HDR и конвейеров с необработанными данными сенсора.

Пиксель — не цветной квадрат с площадью. Это одиночное измерение. При изменении размера или повороте вы повторно дискретизируете эту измерительную сетку.

Почему три канала

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

One pixel in memory:

    (R, G, B) = (210, 140, 30)   <- reddish-orange

An H x W RGB image:

    shape (H, W, 3)     stored as   H rows of W pixels of 3 values
                                    each in [0, 255] for uint8

Три — не магическое число. Камеры глубины добавляют канал Z. Спутники добавляют инфракрасные и ультрафиолетовые полосы. Медицинские сканы часто имеют один канал (рентген, КТ) или много каналов (гиперспектральные изображения). Число каналов — это последняя ось; свёрточные слои учатся смешивать их.

Два соглашения о раскладке: HWC и CHW

Один и тот же тензор, два порядка осей. Каждая библиотека выбирает свой.

HWC (height, width, channels)           CHW (channels, height, width)

   W ->                                    H ->
  +-----+-----+-----+                     +-----+-----+
H |R G B|R G B|R G B|                   C |R R R R R R|
| +-----+-----+-----+                   | +-----+-----+
v |R G B|R G B|R G B|                   v |G G G G G G|
  +-----+-----+-----+                     +-----+-----+
                                          |B B B B B B|
                                          +-----+-----+

   PIL, OpenCV, matplotlib,              PyTorch, most deep learning
   almost every image file on disk       frameworks, cuDNN kernels

CHW существует, потому что ядра свёртки скользят по H и W. Сохранение оси каналов первой означает, что каждое ядро видит непрерывную двумерную плоскость для каждого канала, что удобно векторизуется. Форматы на диске используют HWC, потому что это соответствует тому, как строки развёртки выходят из сенсора.

Строка преобразования, которую вы наберёте тысячу раз:

img_chw = img_hwc.transpose(2, 0, 1)      # NumPy
img_chw = img_hwc.permute(2, 0, 1)        # PyTorch tensor

Визуализация раскладки памяти:

Диаграмма к уроку «Основы изображений: пиксели, каналы, цветовые пространства»

Диапазоны байтов и dtype

Доминируют три соглашения:

Соглашение dtype Диапазон Где встречается
Необработанное uint8 [0, 255] Файлы на диске, вывод PIL и OpenCV
Нормализованное float32 [0.0, 1.0] После img.astype('float32') / 255
Стандартизованное float32 примерно [-2, +2] После вычитания среднего и деления на std

Свёрточные сети обучались на стандартизованных входах. Статистики ImageNet mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225] — это арифметическое среднее и стандартное отклонение трёх каналов по всему обучающему набору ImageNet, рассчитанные на нормализованных в [0, 1] пикселях. Подача необработанного uint8 в модель, ожидающую стандартизованный float, — самый частый тихий сбой в прикладном компьютерном зрении.

Цветовые пространства и зачем они нужны

RGB — это формат захвата, но не всегда наиболее полезное представление для модели.

 RGB               HSV                       YCbCr / YUV

 R red             H hue (angle 0-360)       Y luminance (brightness)
 G green           S saturation (0-1)        Cb chroma blue-yellow
 B blue            V value/brightness (0-1)  Cr chroma red-green

 Linear to         Separates color from      Separates brightness from
 sensor output     brightness. Useful for    color. JPEG and most video
                   color thresholding, UI    codecs compress the chroma
                   sliders, simple filters   channels harder because the
                                             human eye is less sensitive
                                             to chroma detail than to Y.

Для большинства современных CNN вы подаёте RGB. Другие пространства встретятся, когда:

  • HSV — классический код CV, сегментация по цвету, баланс белого.
  • YCbCr — чтение внутреннего устройства JPEG, видеоконвейеры, модели сверхразрешения, работающие только с Y.
  • Оттенки серого (grayscale) — OCR, модели документов и любой случай, где цвет — мешающая переменная, а не сигнал.

Оттенки серого из RGB получаются взвешенной суммой, а не средним, потому что человеческий глаз чувствительнее к зелёному, чем к красному или синему:

Y = 0.299 R + 0.587 G + 0.114 B       (ITU-R BT.601, the classic weights)

Соотношение сторон, изменение размера и интерполяция

Каждая модель имеет фиксированный размер входа (224x224 для большинства классификаторов ImageNet, 384x384 или 512x512 для современных детекторов). Ваши изображения редко совпадают с ним. Важны три варианта изменения размера:

  • Изменить короткую сторону, затем вырезать центр — стандартный рецепт ImageNet. Сохраняет соотношение сторон, но отбрасывает полосу пикселей по краям.
  • Изменить размер и дополнить полями — сохраняет соотношение сторон и каждый пиксель, добавляет чёрные полосы. Стандарт для обнаружения объектов и OCR.
  • Изменить размер прямо до целевого — растягивает изображение. Это дёшево и искажает геометрию, но подходит для многих задач классификации.

Метод интерполяции определяет, как вычисляются промежуточные пиксели, если новая сетка не совпадает со старой:

Nearest neighbour     fastest, blocky, only choice for masks/labels
Bilinear              fast, smooth, default for most image resizing
Bicubic               slower, sharper on upscaling
Lanczos               slowest, best quality, used for final display

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

conv-output-size

Соберите это

Шаг 1: Загрузите изображение и изучите его форму

Используйте Pillow, чтобы загрузить любой JPEG или PNG, преобразуйте его в NumPy и выведите полученный результат. Для детерминированного примера, работающего офлайн, синтезируйте изображение.

import numpy as np
from PIL import Image

def synthetic_rgb(h=128, w=192, seed=0):
    rng = np.random.default_rng(seed)
    yy, xx = np.meshgrid(np.linspace(0, 1, h), np.linspace(0, 1, w), indexing="ij")
    r = (np.sin(xx * 6) * 0.5 + 0.5) * 255
    g = yy * 255
    b = (1 - yy) * xx * 255
    rgb = np.stack([r, g, b], axis=-1) + rng.normal(0, 6, (h, w, 3))
    return np.clip(rgb, 0, 255).astype(np.uint8)

arr = synthetic_rgb()
# Or load from disk:
# arr = np.asarray(Image.open("your_image.jpg").convert("RGB"))

print(f"type:   {type(arr).__name__}")
print(f"dtype:  {arr.dtype}")
print(f"shape:  {arr.shape}     # (H, W, C)")
print(f"min:    {arr.min()}")
print(f"max:    {arr.max()}")
print(f"pixel at (0, 0): {arr[0, 0]}")

Ожидаемый вывод: shape: (H, W, 3), dtype: uint8, диапазон [0, 255]. Это каноническое представление на диске независимо от того, пришли ли байты из камеры, JPEG-декодера или синтетического генератора.

Шаг 2: Разделите каналы и измените порядок раскладки

Извлеките R, G и B по отдельности, затем преобразуйте HWC в CHW для PyTorch.

R = arr[:, :, 0]
G = arr[:, :, 1]
B = arr[:, :, 2]
print(f"R shape: {R.shape}, mean: {R.mean():.1f}")
print(f"G shape: {G.shape}, mean: {G.mean():.1f}")
print(f"B shape: {B.shape}, mean: {B.mean():.1f}")

arr_chw = arr.transpose(2, 0, 1)
print(f"\nHWC shape: {arr.shape}")
print(f"CHW shape: {arr_chw.shape}")

Три плоскости оттенков серого — по одной на канал. CHW лишь меняет порядок осей; при подходящей раскладке памяти строго говоря копирование данных не требуется.

Шаг 3: Преобразования в оттенки серого и HSV

Сначала взвешенная сумма для оттенков серого, затем ручное преобразование RGB в HSV.

def rgb_to_grayscale(rgb):
    weights = np.array([0.299, 0.587, 0.114], dtype=np.float32)
    return (rgb.astype(np.float32) @ weights).astype(np.uint8)

def rgb_to_hsv(rgb):
    rgb_f = rgb.astype(np.float32) / 255.0
    r, g, b = rgb_f[..., 0], rgb_f[..., 1], rgb_f[..., 2]
    cmax = np.max(rgb_f, axis=-1)
    cmin = np.min(rgb_f, axis=-1)
    delta = cmax - cmin

    h = np.zeros_like(cmax)
    mask = delta > 0
    rmax = mask & (cmax == r)
    gmax = mask & (cmax == g)
    bmax = mask & (cmax == b)
    h[rmax] = ((g[rmax] - b[rmax]) / delta[rmax]) % 6
    h[gmax] = ((b[gmax] - r[gmax]) / delta[gmax]) + 2
    h[bmax] = ((r[bmax] - g[bmax]) / delta[bmax]) + 4
    h = h * 60.0

    s = np.where(cmax > 0, delta / cmax, 0)
    v = cmax
    return np.stack([h, s, v], axis=-1)

gray = rgb_to_grayscale(arr)
hsv = rgb_to_hsv(arr)
print(f"gray shape: {gray.shape}, range: [{gray.min()}, {gray.max()}]")
print(f"hsv   shape: {hsv.shape}")
print(f"hue range: [{hsv[..., 0].min():.1f}, {hsv[..., 0].max():.1f}] degrees")
print(f"sat range: [{hsv[..., 1].min():.2f}, {hsv[..., 1].max():.2f}]")
print(f"val range: [{hsv[..., 2].min():.2f}, {hsv[..., 2].max():.2f}]")

Оттенок получается в градусах, а насыщенность и значение — в [0, 1]. Это совпадает с соглашением OpenCV hsv_full.

Шаг 4: Нормализуйте, стандартизируйте и выполните обратное преобразование

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

mean = np.array([0.485, 0.456, 0.406], dtype=np.float32)
std = np.array([0.229, 0.224, 0.225], dtype=np.float32)

def preprocess_imagenet(rgb_uint8):
    x = rgb_uint8.astype(np.float32) / 255.0
    x = (x - mean) / std
    x = x.transpose(2, 0, 1)
    return x

def deprocess_imagenet(chw_float32):
    x = chw_float32.transpose(1, 2, 0)
    x = x * std + mean
    x = np.clip(x * 255.0, 0, 255).astype(np.uint8)
    return x

x = preprocess_imagenet(arr)
print(f"preprocessed shape: {x.shape}     # (C, H, W)")
print(f"preprocessed dtype: {x.dtype}")
print(f"preprocessed mean per channel:  {x.mean(axis=(1, 2)).round(3)}")
print(f"preprocessed std  per channel:  {x.std(axis=(1, 2)).round(3)}")

roundtrip = deprocess_imagenet(x)
max_diff = np.abs(roundtrip.astype(int) - arr.astype(int)).max()
print(f"roundtrip max pixel diff: {max_diff}    # should be 0 or 1")

Среднее по каждому каналу должно быть близко к нулю, а std — к единице. Пара preprocess/deprocess в точности выполняет то же, что каждый вызов transforms.Normalize в torchvision, но «под капотом».

Шаг 5: Измените размер с тремя методами интерполяции

Сравните ближайшего соседа, билинейную и бикубическую интерполяцию при увеличении, чтобы разница была видна.

target = (arr.shape[0] * 3, arr.shape[1] * 3)

nearest = np.asarray(Image.fromarray(arr).resize(target[::-1], Image.NEAREST))
bilinear = np.asarray(Image.fromarray(arr).resize(target[::-1], Image.BILINEAR))
bicubic = np.asarray(Image.fromarray(arr).resize(target[::-1], Image.BICUBIC))

def local_roughness(x):
    gy = np.diff(x.astype(float), axis=0)
    gx = np.diff(x.astype(float), axis=1)
    return float(np.abs(gy).mean() + np.abs(gx).mean())

for name, out in [("nearest", nearest), ("bilinear", bilinear), ("bicubic", bicubic)]:
    print(f"{name:>8}  shape={out.shape}  roughness={local_roughness(out):6.2f}")

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

Примените это

torchvision.transforms объединяет всё описанное выше в единый компонуемый конвейер. Код ниже в точности воспроизводит работу preprocess_imagenet и добавляет изменение размера и обрезку.

import torch
from torchvision import transforms
from PIL import Image

img = Image.fromarray(synthetic_rgb(256, 256))

pipeline = transforms.Compose([
    transforms.Resize(256),
    transforms.CenterCrop(224),
    transforms.ToTensor(),
    transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
])

x = pipeline(img)
print(f"tensor type:  {type(x).__name__}")
print(f"tensor dtype: {x.dtype}")
print(f"tensor shape: {tuple(x.shape)}      # (C, H, W)")
print(f"per-channel mean: {x.mean(dim=(1, 2)).tolist()}")
print(f"per-channel std:  {x.std(dim=(1, 2)).tolist()}")

batch = x.unsqueeze(0)
print(f"\nbatched shape: {tuple(batch.shape)}   # (N, C, H, W) — ready for a model")

Четыре шага в строго таком порядке: Resize(256) масштабирует короткую сторону до 256; CenterCrop(224) берёт из центра фрагмент 224x224; ToTensor() делит на 255 и меняет HWC на CHW; Normalize вычитает среднее ImageNet и делит на std. Обратный порядок молча меняет то, что достигает модели.

Подготовьте к поставке

Этот урок создаёт:

  • outputs/prompt-vision-preprocessing-audit.md — промпт, превращающий любую карточку модели или набора данных в контрольный список точных инвариантов предобработки, которые должна соблюдать команда.
  • outputs/skill-image-tensor-inspector.md — навык, который для любого тензора или массива формы изображения сообщает dtype, раскладку, диапазон и то, похож ли он на необработанный, нормализованный или стандартизованный.

Упражнения

  1. (Легко) Загрузите JPEG с OpenCV (cv2.imread) и Pillow. Выведите обе формы и пиксель в (0, 0). Объясните разницу порядка каналов, затем напишите однострочное преобразование, делающее массив OpenCV идентичным массиву Pillow.
  2. (Средне) Напишите standardize(img, mean, std) и обратную ему функцию, которые вместе проходят тест roundtrip_max_diff <= 1 на любом изображении uint8. Ваши функции должны одинаково работать для одиночного изображения HWC и пакета NCHW.
  3. (Сложно) Возьмите трёхканальный тензор, стандартизованный по ImageNet, и пропустите его через свёртку 1x1, которая учится взвешенно смешивать RGB в один канал оттенков серого. Инициализируйте веса как [0.299, 0.587, 0.114], заморозьте их и проверьте, что результат совпадает с ручной rgb_to_grayscale в пределах ошибки плавающей точки. Какие ещё классические преобразования цветовых пространств можно записать как свёртки 1x1?

Ключевые термины

Термин Как обычно говорят Что это на самом деле означает
Пиксель «Цветной квадрат» Одно измерение интенсивности света в одной позиции сетки: три числа для цвета, одно для оттенков серого
Канал «Цвет» Одна из параллельных пространственных сеток, сложенных в тензор изображения; последняя ось в HWC, первая в CHW
HWC / CHW «Форма» Порядки осей тензора изображения; диск и PIL используют HWC, PyTorch и cuDNN — CHW
Нормализовать «Масштабировать изображение» Разделить на 255, чтобы пиксели оказались в [0, 1]; это необходимо, но недостаточно
Стандартизировать «Центрировать относительно нуля» Вычесть среднее и разделить на std для каждого канала, чтобы распределение входа совпало с тем, на котором обучалась модель
Преобразование в оттенки серого «Усреднить каналы» Взвешенная сумма с коэффициентами 0.299/0.587/0.114, соответствующая восприятию яркости человеком
Интерполяция «Как изменение размера выбирает пиксели» Правило, определяющее значения выхода, когда новая сетка не совпадает со старой: ближайший сосед для меток, билинейная для обучения, бикубическая для отображения
Соотношение сторон «Ширина к высоте» Отношение, отличающее «изменить размер и дополнить полями» от «изменить размер и растянуть»

Дополнительное чтение


Источник: Image Fundamentals — Pixels, Channels, Color Spaces 03.13 — Отладка нейронных сетей · Фаза 4 — Компьютерное зрение · Полный каталог · 04.02 — Свёртки с нуля