Добрый день.

Для своей первой статьи я выбрал одну из самых успешных своих поделок: HDMI-passthrough аналог Ambilight от Philips, далее я будут называть эту композицию «Атмосвет».

Введение

В интернетах не очень сложно найти готовые/открытые решения и статьи как сделать Амбилайт для монитора/телевизора, если ты выводишь картинку с ПК. Но в моей мультимедиа системе вывод картинки на телевизор c ПК занимает только 5% времени использования, большее кол-во времени я играю с игровых консолей, а значит нужно было придумать что-то свое.


Исходные данные:

  • 60" Плазменный телевизор
  • HTPC на базе Asrock Vision 3D 137B
  • Xbox 360
  • PS3
  • PS4
  • WiiU

Большинство устройств используют HDCP для воспроизведения контента даже во время игры.
Требование:

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

Реализация

Я не буду рассказывать, как я прикреплял 4.5м светодиодную ленту к телевизору и что нужно сделать с Arduino, в качестве базы можно использовать эту статью.

Единственный нюанс:
Я заметил, что внизу экрана идут странные мерцания, сначала погрешил на сигнал, перековырял дефликер, изменил ресазинг картинки и еще кучу всего перекопал, стало лучше, но от мерцания не помогло. Стал наблюдать. Оказалось, что мерцание было только в конце ленты и то при ярких сценах. Взяв мультиметр, я замерил напряжение на начале, середине и конце ленты и угадал с причиной мерцаний: в начале ленты было 4.9В( да китайский БП дает напряжение с отклонением, это не существенно), в середине 4.5 в конце 4.22 — Падение напряжение слишком существенно, пришлось решить проблему просто — к середине ленты я подвел питание от бп, провод пустил за телевизором. Помогло мгновенно, какие либо мерцания прекратились вообще.

Захватываем картинку вебкамерой

Первая тестовая версия для обкатки идеи и её визуализации была выбрана через захват картинки через вебкамеру) выглядело это как-то так:
image

Низкая цветопередача и высокий latency показал, что эта реализация не может быть никак использована.

Захват картинки через HDMI


В процессе исследования возможных вариантов была выбрана следующая схема, как сама надежная и бюджетная:
  • Сигнал со всех устройств подается на 5in-1out HDMI свитч, который поддерживает HDCP
  • Выходной сигнал подается на 1in-2out HDMI splitter, который мало того, что поддерживает HDCP, так еще отключайте его на выходе(слава китайцам).
  • Один из выходных сигналов идет на телевизор
  • Другой выходной сигнал идет на HDMI to AV конвертер
  • S-Video сигнал идет на коробочку захвата от ICONBIT
  • Коробочка захвата подключается к вечно работающему HTCP по USB, который подключен к Arduino контроллеру ленте на телевизоре.


Изначально выглядит дико и как костыли, но:
  • Это работает.
  • Сумарно все это дело, заказывая из китая, мне обошлось тысяч в 3-4 тыс. рублей.


Почему я не использовал плату для HDMI захвата? Все просто: самый дешевый вариант и доступный — это Blackmagic Intensity Shuttle, но она не может работать с сигналом 1080p/60fps, только с 1080p/30fps — что не приемлемо, т.к. я не хотел понижать фреймрейт, чтобы можно было захватывать картинку. + это дело стоило в районе 10 тыc. рублей. — что не дешево при неизвестном результате.

Потери на конвертации HDMI to S-video несущественны для захвата цвета в разрешении 46х26 светодиодной подсветки.

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

Единственный минус — выходной сигнал S-Video содержал черные полосы по краям срезающий реальный контент(около 2-5%), выходную картинку с платы захвата я обрезал, чтобы удалить эти полосы, сама потеря изображения в тех областях на практике не сказалась на результате.

Софт

Для меня это была самая интересная часть, т.к. с железками я не очень люблю ковыряться.

Для захвата картинки я использовал openCV и в частности его .NET враппер emgu CV.

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

Процесс обработки фрейма

1. Получение захваченного фрейма

2. Кроп фрейма, для исключения черных полос

Тут все просто:
frame.ROI = new Rectangle(8, 8, frame.Width - 8, frame.Height - 18 - 8); 

Обрезаем 8 пикселей сверху, 8 справа и 18 снизу.(слева полосы нет)

3. Ресайзим фрейм в разрешение подсветки, незачем нам таскать с собой здоровую картинку

Тоже ничего сложного, делаем это средствами openCV:
frame.Resize(LedWidth — 2*LedSideOverEdge,
LedHeight — LedBottomOverEdge — LedTopOverEdge,
INTER.CV_INTER_LINEAR);
Внимательный читатель заметит, обилие переменных. Дело в том, что у меня рамка телевизора достаточно большая, занимая 1 светодиод по бокам, 1 сверху и 3 снизу, поэтому ресайз делается на светодиоды, которые находятся непосредственно напротив дисплея, а углы мы уже дополняем потом. При ресайзинге мы как раз получаем усредненные цвета, которые должны будут иметь пиксели светодиодов.

4. Выполняем мапинг светодиодов с отреcайзенного фрейма

Ну тут тоже все просто, тупо проходим по каждой стороне и последовательно заполняем массив из 136 значений цветом светодиодов. Так вышло, что на текущий момент все остальные операции проще выполнять с массивом светодиодов, чем с фреймом, который тяжелее в обработке. Также на будущее я добавил параметр «глубины» захвата(кол-во пикселей от границы экрана, для усреднения цвета светодиода), но в конечном сетапе, оказалось лучше без неё.

5. Выполняем коррекцию цвета (баланс белого/цветовой баланс)

Стены за телевизором у меня из бруса, брус желтый, поэтому нужно компенсировать желтизну.
var blue = 255.0f/(255.0f + blueLevelFloat)*pixelBuffer[k];
var green = 255.0f/(255.0f + greenLevelFloat)*pixelBuffer[k + 1];
var red = 255.0f/(255.0f + redLevelFloat)*pixelBuffer[k + 2];
Вообще я изначально из исходников какого-то опенсорс редактора взял цветовой баланс, но он не менял белый(белый оставался белым), я поменял формулы немного, опечатался, и получил прям то, что нужно: если level компонента цвета отрицательный(я поинмаю как — этого цвета не хватает), то мы добавляем его интенсивность и наоборот. Для моих стен это получилось: RGB(-30,5,85).

В кореркции цвета я также выполняю выравнивание уровня черного(черный приходит где-то на уровне 13,13,13 по RGB), просто вычитая 13 из каждой компоненты.

6. Выполняем десатурацию (уменьшение насыщенности изображения)

В конечном сетапе, я не использую десатурацию, но может в определенный момент понадобится, фактически это делает цвета более «пастельными», как у Филипсовского амбилайта. Код приводить не буду, мы просто конвертим из RGB -> HSL, уменьшаем компоненту Saturation(насыщенность) и возвращаемся обратно уже в RGB.

7. Дефликер

Так уж выходит, что входное изображение «дрожит» — это следствие конвертации в аналоговый сигнал, как я полагаю. Я сначала пытался решить по своему, потом подсмотрел в исходники Defliker фильтра, используемом в VirtualDub, переписал его на C#(он был на С++), понял, что он не работает, ибо он такое впечталение, что борется с мерцаниями между кадрами, в итоге я совместил свое решение и этот дефликер получив что-то странное, но работающее лучше чем ожидалось. Изначальный дефликер работал только с интенсивностью всего фрейма, мне нужно по каждому светодиоду отдельно. Изначальный дефл��кер сравнивал изменение интенсивности как суммы, мне больше нравится сравнение длинны вектора цвета, Изначальный дефликер сравнивал дельту изменения интенсивности по сравнению с предыдущим кадром, это не подходит, и я переделал на среднюю величину интенсивности в пределах окна предыдущих кадров. И еще много других мелочей, в результате чего от начального дефликера мало что осталось.
Основная идея: исходя из средней интенсивности предыдущих кадров, выполнять модификацию текущего кадра, если его интенсивность не выше определенного порога (у меня этот порог в конечном сетапе 25), если порог преодолевается, то производится сброс окна, без модификации.
Немного модифицированный (для читаемости вне контекста) код моего дефликера:
Array.Copy(_leds, _ledsOld, _leds.Length);  
       for (var i = 0; i < _leds.Length; i++)  
       {  
         double lumSum = 0;  
         // Calculate the luminance of the current led.  
         lumSum += _leds[i].R*_leds[i].R;  
         lumSum += _leds[i].G*_leds[i].G;  
         lumSum += _leds[i].B*_leds[i].B;  
         lumSum = Math.Sqrt(lumSum);  
         // Do led processing  
         var avgLum = 0.0;  
         for (var j = 0; j < LedLumWindow; j++)  
         {  
           avgLum += _lumData[j, i];  
         }  
         var avg = avgLum/LedLumWindow;  
         var ledChange = false;  
         if (_strengthcutoff < 256 && _lumData[0, i] != 256 &&  
           Math.Abs((int) lumSum - avg) >= _strengthcutoff)  
         {  
           _lumData[0, i] = 256;  
           ledChange = true;  
         }  
         // Calculate the adjustment factor for the current led.  
         var scale = 1.0;  
         int r, g, b;  
         if (ledChange)  
         {  
           for (var j = 0; j < LedLumWindow; j++)  
           {  
             _lumData[j, i] = (int) lumSum;  
           }  
         }  
         else  
         {  
           for (var j = 0; j < LedLumWindow - 1; j++)  
           {  
             _lumData[j, i] = _lumData[j + 1, i];  
           }  
           _lumData[LedLumWindow - 1, i] = (int) lumSum;  
           if (lumSum > 0)  
           {  
             scale = 1.0f/((avg+lumSum)/2);  
             var filt = 0.0f;  
             for (var j = 0; j < LedLumWindow; j++)  
             {  
               filt += (float) _lumData[j, i]/LedLumWindow;  
             }  
             scale *= filt;  
           }  
           // Adjust the current Led.  
           r = _leds[i].R;  
           g = _leds[i].G;  
           b = _leds[i].B;  
           // save source values  
           var sr = r;  
           var sg = g;  
           var sb = b;  
           var max = r;  
           if (g > max) max = g;  
           if (b > max) max = b;  
           double s;  
           if (scale*max > 255) s = 255.0/max;  
           else s = scale;  
           r = (int) (s*r);  
           g = (int) (s*g);  
           b = (int) (s*b);  
           // keep highlight  
           double k;  
           if (sr > _lv)  
           {  
             k = (sr - _lv)/(double) (255 - _lv);  
             r = (int) ((k*sr) + ((1.0 - k)*r));  
           }  
           if (sg > _lv)  
           {  
             k = (sg - _lv)/(double) (255 - _lv);  
             g = (int) ((k*sg) + ((1.0 - k)*g));  
           }  
           if (sb > _lv)  
           {  
             k = (sb - _lv)/(double) (255 - _lv);  
             b = (int) ((k*sb) + ((1.0 - k)*b));  
           }  
           _leds[i] = Color.FromArgb(r, g, b);  
         }  
         /* Temporal softening phase. */  
         if (ledChange || _softening == 0) continue;  
         var diffR = Math.Abs(_leds[i].R - _ledsOld[i].R);  
         var diffG = Math.Abs(_leds[i].G - _ledsOld[i].G);  
         var diffB = Math.Abs(_leds[i].B - _ledsOld[i].B);  
         r = _leds[i].R;  
         g = _leds[i].G;  
         b = _leds[i].B;  
         int sum;  
         if (diffR < _softening)  
         {  
           if (diffR > (_softening >> 1))  
           {  
             sum = _leds[i].R + _leds[i].R + _ledsOld[i].R;  
             r = sum/3;  
           }  
         }  
         if (diffG < _softening)  
         {  
           if (diffG > (_softening >> 1))  
           {  
             sum = _leds[i].G + _leds[i].G + _ledsOld[i].G;  
             g = sum/3;  
           }  
         }  
         if (diffB < _softening)  
         {  
           if (diffB > (_softening >> 1))  
           {  
             sum = _leds[i].B + _leds[i].B + _ledsOld[i].B;  
             b = sum/3;  
           }  
         }  
         _leds[i] = Color.FromArgb(r, g, b);  
       }  

Пусть _leds — массив светодиодов класса Color, _ledsOld — значения кадра до конвертации, LedLumWindow — ширина окна предыдущих кадров, для оценки среднего изменения интенсивности, в конечном сетапе окно у меня было 100, что примерно при 30кад/с равняется 3-секундам. _lumData — массив значения интенсивности предыдущих кадров.

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

8. Сглаживание светодиодов по соседям.

Вообще в конечном сетапе, сглаживание мне не очень понравилось, и я его отключил, но в некоторых случаях может пригодиться. Тут мы просто усредняем цвет каждого светодиода по его соседним.
var smothDiameter = 2*_smoothRadius + 1;  
       Array.Copy(_leds, _ledsOld, _leds.Length);  
       for (var i = 0; i < _ledsOld.Length; i++)  
       {  
         var r = 0;  
         var g = 0;  
         var b = 0;  
         for (var rad = -_smoothRadius; rad <= _smoothRadius; rad++)  
         {  
           var pos = i + rad;  
           if (pos < 0)  
           {  
             pos = _ledsOld.Length + pos;  
           }  
           else if (pos > _ledsOld.Length - 1)  
           {  
             pos = pos - _ledsOld.Length;  
           }  
           r += _ledsOld[pos].R;  
           g += _ledsOld[pos].G;  
           b += _ledsOld[pos].B;  
         }  
         _leds[i] = Color.FromArgb(r/smothDiameter, g/smothDiameter, b/smothDiameter);  
       }  



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

Я умышленно разделил процесс обработки кадров и отправки пакетов на контроллер: пакеты отправляются раз в определенный интервал(у меня это 40мс), чтобы ардуино спела обработать предыдущий, ибо чаще чем 30мс она захлебывается, таким образом выходит, что мы не зависим напрямую от частоты кадров захвата и не мешаем тому процессу(а ведь отправка пакета тоже тратит время).

Немного про ардуино

Нельзя просто так взять и отправить по сериалу здоровенный пакет на ардуино, ибо онв ыйдет за пределы дефолтного буфера HardwareSerial и ты потеряешь его конец.
Решается это довольно просто: выставляем значение размера буфера HardwareSerial достаточного размера, чтобы влезал весь отправляемый пакет с массивом цветов, для меня это 410.

UI

Сам софт был реализован в виде win службы, чтобы настраивать все параметры + включать/отключать я сделал Web UI, который связывался с службой через WebService на службе. Итоговый интерфейс на экране мобильника выглядит так:
image

Сейчас планирую прикрутить голосовое управление через Kinect for Windows подключенном к HTCP.

Результат

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

Как общий результат работы я записал видео с работой атмосвета по моей схеме:

Испытуемый образец 1: Pacific Rim, сцена битвы в Шанхае, этот фильм хорошо подходит для тестирования и демонстрации, много ярких сцен и вспышек, ударов молнии и т.д.:



Испытуемый образец 2: Какой-то ролик из MLP, слитый с ютуба, очень хорошо подходит для теста сцен с яркими цветами(мне понравились полосы), а также быстро сменяющихся сцен(под конец виде можно разглядеть последствия задержки, видных только на видео, при реальном просмотре этого не заметно, пробовал измерить задержку по видео — получилось 10-20мс):



И на последок стоит заметить про потребление ресурсов от HTPC:
HTPC у меня ASRock Vision 3D на i3, служба атмосвета отжирает 5-10% CPU и 32MB RAM.

Спасибо за внимание, очень надеюсь, что кому-нибудь моя статья поможет.