happyin.work / блог

English · Русский · Español · Deutsch · Português (Brasil) · 简体中文 · 日本語

Channel mixer, матрица 3×3: что сохраняет, где клипует, где работает

Автор: Anastasiia Butova, инженер по ComfyUI и диффузионным моделям, Белград · опубликовано 2026-09-23

Перевод английской версии: English

После перцептивных инструментов диапазонов из части 4 channel mixer — самый грубый инструмент в моей колор-студии: три выходных канала, каждый из которых — взвешенная сумма трёх входных, всего девять чисел. Код занимает несколько строк. Интересные вопросы — вокруг него: какой RGB он смешивает, что значит «сохранить», что происходит за пределами 0..1 и где он стоит в цепочке. Я добавила его в студию 20 сентября. Ниже — как он работает и, так же прямо, чего он не делает.

Вся операция

[ R′ ]   [ m00 m01 m02 ] [ R ]
[ G′ ] = [ m10 m11 m12 ] [ G ]
[ B′ ]   [ m20 m21 m22 ] [ B ]

Каждый коэффициент должен быть конечным и лежать в [−4, 4]; по умолчанию — единичная матрица. В C++-движке (color.cpp) умножение и его стадия выглядят так:

Vec3 applyChannelMatrix(const std::array<double, 9>& matrix, const Vec3& encodedRgb) {
  return {
      matrix[0] * encodedRgb[0] + matrix[1] * encodedRgb[1] + matrix[2] * encodedRgb[2],
      matrix[3] * encodedRgb[0] + matrix[4] * encodedRgb[1] + matrix[5] * encodedRgb[2],
      matrix[6] * encodedRgb[0] + matrix[7] * encodedRgb[1] + matrix[8] * encodedRgb[2],
  };
}
// ... inside Transform::apply, after curves and the photo stage:
if (hasChannelMatrix) {
  out = applyChannelMatrix(grade.channelMatrix, out);
  for (double& value : out) value = clamp(value);
}

hasChannelMatrix равен false для точной единичной матрицы, так что нетронутый микшер не стоит ничего и ничего не меняет. Проекты, сохранённые до появления микшера, не имеют поля channelMatrix вовсе. Парсер читает это как единичную матрицу, а отдельная проверка подтверждает, что такой старый грейд рендерится с разницей ровно 0 и в JavaScript-реализации, и в C++-движке.

Какой RGB он смешивает

Матрица работает с кодированным sRGB — теми же значениями 0..1, которые хранит PNG или JPEG, а не с линейным светом. Это записано в контракт инструмента как «кодированный sRGB → кодированный sRGB», и у этого есть заметное следствие: смешивать закодированные значения — не то же самое, что смешивать свет.

Возьмём чистый красный и строку R′ = 0.5 R + 0.5 G. В кодированном sRGB красный выход равен 0.5. То же смешение, выполненное в линейном свете и закодированное обратно, даёт 0.735. Смешение в кодированных значениях заметно темнее, и разрыв растёт с разницей между смешиваемыми каналами. Линейного режима в текущем движке нет, поэтому рецепт, написанный для линейного микшера, не переносится сюда теми же числами.

Что сохраняет сумма строки, а что нет

Если сумма каждой строки равна 1, серый вход [x, x, x] остаётся [x, x, x], потому что M·1 = 1. Интерфейс показывает три суммы строк рядом с матрицей и словами говорит, сохраняются ли нейтральные тона. Проверка по математике подтверждает это с точностью 1.1 × 10⁻¹⁶ на серых 0.18, 0.54 и 1 с матрицей, строки которой равны (0.70, 0.20, 0.10), (0.15, 0.70, 0.15) и (0.20, 0.20, 0.60).

Это сохранение нейтральных тонов, и его легко принять за сохранение яркости. Это не так. Яркость (luma) — это w·RGB, где w = (0.2126, 0.7152, 0.0722), и чтобы сохранить яркость каждого цвета, нужно wᵀM = wᵀ: условие на столбцы, взвешенные по w, а не на строки. Поменяйте местами красный и синий: правило строк выполнено идеально, все серые сохранились, но чистый красный падает по яркости с 0.2126 до 0.0722. У микшера нет режима сохранения яркости; суммы строк — единственный инвариант, который он показывает.

Обратная ошибка так же легка. R′ = R + 0.20 G делает сумму красной строки равной 1.2, и на синтетической цветовой мишени ниже она окрашивает серую шкалу в красный — до 38 кодов. Масштабирование строки до R′ = 0.80 R + 0.20 G всё равно переносит часть зелёного в красный канал, а серые остаются ровно на месте.

Пять версий синтетической таблицы цветов: исходная; красная строка плюс 0.2 зелёного, которая окрашивает серые в розовый; та же строка, нормированная к сумме 1, сохраняет серые; три одинаковые строки яркости дают монохром; и 1.6 R минус 0.6 G, которая сохраняет серые, но клиппит 8 из 72 значений каналов

Нет столбца смещения

У Channel Mixer в Photoshop есть ползунок Constant от −200 % до +200 %, который делает выходной канал темнее или светлее, и ползунки источников в пределах ±200 % (Adobe: mix colour channels). У моего смещения нет. Это чистое отображение 3×3, а не аффинное 3×4. Коэффициенты доходят до ±4, то есть до ±400 %. Фотостадия движка содержит поканальный Levels с чёрной и белой точкой выхода для R, G и B, но ни один элемент интерфейса их пока не записывает (часть 2 об этом говорит), и они работают до микшера, а не после. Поднять или прижать канал после микширования сегодня просто нельзя. Я не утверждаю численного совпадения с Photoshop или каким-либо другим закрытым редактором; матрица — моя собственная, заявленная контрактом.

Монохром без галочки

Переключателя Monochrome нет. Три одинаковые строки дают монохромное изображение, а значения строк задают внешний вид. С (0.2126, 0.7152, 0.0722) в каждой строке выход — это яркость Rec. 709, посчитанная по кодированным значениям, Y′, а не относительная яркость: веса стандартные, а область — гамма-кодированная. Суммы строк равны 1, так что серые остаются на месте, и таблица превращается в чистую серую шкалу.

Пресеты чёрно-белого в Channel Mixer Photoshop описаны так же — как тройки весов (не путать с отдельной корректировкой Black & White, у которой часть названий совпадает): пресет жёлтого фильтра — Red 34 %, Green 66 %, Blue 0 %, а инфракрасный — Red −70 %, Green 200 %, Blue −30 %. Если вписать такую тройку во все три строки, здесь получится сопоставимое смешение. Я не сравнивала пиксели с выходом Photoshop, поэтому не стала бы обещать их совпадение.

Клиппинг и гамут

После M·RGB каждый выходной канал обрезается до [0, 1]. Комментарий в движке формулирует это как объявленное поведение SDR: строки могут намеренно выходить за диапазон дисплея и поэтому клиппить, а не менять смысл более раннего сжатия гамута в Oklab. Отрицательные коэффициенты — обычный способ туда попасть. R′ = 1.6 R − 0.6 G сохраняет серые (строка даёт 1) и всё же выталкивает 8 из 72 значений каналов на таблице за пределы диапазона. Они обрезаются, и детали там теряются. Проверка для этого подаёт [0.8, 0.3, 0.4] через красную строку (2, 0, 0) и утверждает [1, 0.3, 0.4]: обрезка явная, а не случайность выходного кодировщика.

Микшер работает после мягкого сжатия гамута стадии Oklab, так что помощи от него нет. Цвет, который стадия Oklab мягко загнала внутрь sRGB, матрица может снова вытолкнуть наружу и жёстко обрезать. Контраст обладает тем же свойством; при контрасте 1.5 его обрезка «съедает» 86 из 256 входных кодов, и это открытая задача, а не решённая проблема.

Где он стоит в конвейере

Порядок внутри Transform::apply в color.cpp:

  1. Входная стадия: необязательный Levels для чекера, затем импортированный LUT с собственной силой смешения.
  2. Линейный свет: коэффициенты баланса белого, температура и тинт, экспозиция.
  3. Творческая стадия Oklab: референсное совпадение, оттенок и насыщенность, пины, полосы оттенков; затем, при необходимости, мягкое сжатие гаммута и возврат к кодированному sRGB.
  4. Контраст вокруг 0.5 в кодированном sRGB.
  5. Мастер-кривая, затем кривые R, G и B.
  6. Фотостадия: Levels, кривая яркости, тоновые ползунки, vibrance, колёса цветокоррекции (grading wheels), Selective Color.
  7. Channel mixer, затем обрезка.
  8. Защищённые цвета и блокировки оттенков подмешивают обратно исходный пиксель.
  9. Глобальная сила смешивает весь результат с исходным изображением.

Из этого порядка следуют два вывода. Защиты стоят после микшера, поэтому заблокированный оттенок кожи сохраняет точный исходный RGB, даже если матрица переставляет каналы вокруг него. И каждый шаг до микшера включительно зависит только от собственного RGB пикселя, а не от его позиции или соседей, так что микшер запекается в экспорт CUBE и Hald вместе с остальной глобальной цепочкой.

Как это проверено

  • Семейство проверок по математике прогоняет матрицу через её собственное определение, проверяет нейтральные строки, явную обрезку и legacy-грейд и отклоняет коэффициент 5 с именованной ошибкой.
  • Нативный движок рендерит legacy-грейд с разницей 0.
  • Прогон паритета с фиксированным seed JavaScript и C++ по 600 случайным допустимым грейдам, из которых у каждого с вероятностью 30 % микшер возмущён, сходится до 8.7 × 10⁻¹⁴ на 196 200 значениях каналов.

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

Ограничения

  • Только кодированный sRGB; линейного режима нет.
  • Нет столбца смещения, нет переключателя Monochrome, нет варианта с сохранением яркости. Суммы строк показываются; яркость — нет.
  • Жёсткая обрезка до [0, 1] после матрицы, без собственного сжатия гамута.
  • SDR, 8-битное декодирование Canvas в главном окне. 16-битный путь существует только в Grid Lab.

Следующая и последняя часть — о 3D LUT: как вся эта глобальная цепочка, микшер включительно, запекается в .cube или Hald-изображение и сколько от неё сохраняется между узлами.

Colour tools, built from scratch

  1. Экспозиция и баланс белого: три множителя в линейном свете
  2. Levels: чёрная и белая точки, гамма, каналы и таблица
  3. Кривые: натуральный сплайн против PCHIP, выброс (overshoot), яркость и запекание в LUT
  4. Оттенок, насыщенность и светлота по диапазону цвета, в Oklab, а не в HSL
  5. Channel mixer, матрица 3×3: что сохраняет, где клипует, где работает
  6. 3D LUT: запекание цветового грейда в .cube и Hald и что теряется между узлами

Другие статьи

Ещё на сайте

Anastasiia Butova - ML engineer, Belgrade, Serbia. Email [email protected] · LinkedIn · GitHub · Telegram · Hugging Face