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

Один из способов такого «необычного» освещения комнаты во время просмотра фильмов предлагает компания Philips с технологией Ambilight, встроенной в особо навороченные телевизоры этого бренда.

В этой статье вы обнаружите реализацию подсветки Ambilight с помощью умных ламп Yeelight от Xiaomi!

Об Ambilight


Кто не знает – технология Ambilight представляет собой встроенную в телевизоры фоновую подсветку, которая, анализируя цветовую картинку кадра на экране телевизора, воспроизводит рассеянный свет по периметру телевизора.



Плюсы Ambilight:

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

В общем, Ambilight является довольно интересной технологией, и подтверждением данного факта является наличие большого количества разнообразных вариантов ее «кустарной» реализации, представленных в интернете. Однако все они в подавляющем большинстве основываются на использовании адресной светодиодной ленты, приклееной к тыльной части телевизора/монитора/крышки ноутбука. Для такой реализации необходимо иметь как минимум физический внешний контроллер, отвечающий за управление светодиодами. Это требует специфических знаний от человека, захотевшего установить подобную систему. Поэтому в альтернативу я предлагаю максимально «прогерский» и довольно простой вариант исполнения такой подсветки при помощи умных ламп.

Что за «умные» лампы?


Для создания этого варианта подсветки понадобится любое осветительное устройство бренда Yeelight (дочерняя компания Xiaomi) или Xiaomi (но только те, в названии которых присутствует упоминание Yeelight). Это означает, что устройство встраивается в экосистему умного дома Xiaomi и управляется через приложение Yeelight.



На мой взгляд, адаптивная подсветка – не та фишка, ради которой кто-то побежит покупать умную лампу Xiaomi (за существенные деньги кстати). Однако, как по мне, это хорошая возможность расширить функционал уже имеющейся дома лампы. В любом случае, как обладатель двух ламп Xiaomi, могу заявить, что от двух месяцев их использования у меня остались только приятные впечатления.

Приложении Yeelight играет немаловажную роль в реализации этого проекта, так как в нем присутствует один полезный параметр – Developer mode.


В последних обновлениях его переименовали на «Управление по LAN»

Современная экосистема умного дома основывается на обмене данными между устройствами по протоколу wi-fi. В каждое умное устройство встроен wi-fi модуль, позволяющий подключаться к локальной беспроводной сети. Благодаря этому осуществляется управление устройством через облачный сервис умного дома. Однако Developer mode позволяет связываться c устройством напрямую, отправляя запросы на выделенный устройству IP адрес (адрес устройства можно узнать в приложении Yeelight в информации об устройстве). Этот режим гарантирует прием данных с девайсов, находящихся в одной локальной сети с умной лампой. На сайте Yeelight присутствует небольшое демо функционала режима разработчика.

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

Определение функционала


Дальнейший пост будет посвящен тому, с какими трудностями (и способами их решения) может столкнуться инженер, задумавшийся спроектировать подобное, а также общему прогрессу в реализации задуманного.

Если вас интересует исключительно готовая программа, то вы можете сразу перейти к пункту «Для тех, кто просто хочет пользоваться готовым плеером».

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

  • Необходимо разработать функционал, позволяющий динамически изменять параметры (цвет или яркость/температура света в случае использования устройства без rgb светодиодов) умной лампы в зависимости от текущего изображения в окне медиаплеера.
  • Функционал должен поддерживать использование нескольких умных ламп разных моделей одновременно.
  • Функционал должен иметь механизм независимого анализа нескольких зон изображения, что позволит указывать зону «отслеживания» цвета для конкретной лампы.
  • Текущая конфигурация ламп должна сохраняться и загружаться при последующем использовании программы.
  • Функционал должен встраиваться в выбранный для проекта медиапроигрыватель.


Для тех, кто просто хочет пользоваться готовым плеером


открой меня
Если у вас нет желания разбираться в реализации адаптивной подсветки и вы просто хотите пользоваться готовым плеером, можете скачать уже собранный jar файл из репозитория и затем обязательно прочитать раздел Before you start в README файле из репозитория.


Разработка


Выбор инструментов


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

Мой выбор пал на плеер vlcj player и библиотеку Yapi, написанные на языке Java. В качестве инструмента сборки был использован Maven.

Vlcj представляет собой фреймворк, позволяющий встраивать в приложение Java нативный плеер VLC, а так же управлять жизненным циклом плеера через java код. У автора фреймворка так же имеется демонстрационная версия плеера, который почти полностью повторяет интерфейс и функционал VLC плеера. Самая стабильная версия плеера на текущий момент является версия 3. Именно она будет использована в проекте.


Интерфейс плеера vlcj с открытыми дополнительными окнами

Преимущества vlcj плеера:

  • огромное количество поддерживаемых форматов видео, что является давнишней особенностью плеера VLC;
  • Java как ЯП, что позволяет открывать плеер на большом количестве операционных систем (в данном случае мы ограничены лишь реализацией VLC плеера, который неразрывно связан с java приложением).

Недостатки:

  • устарелый дизайн плеера, который решается собственной реализацией интерфейса;
  • перед использованием программы требуется установка VLC плеера и Java версии не ниже 8, что определенно тянет на недостаток.

Использование Yapi в качестве библиотеки для соединения с умными гаджетами Yeelight можно обосновать в первую очередь простотой, а во вторых – немногочисленностью готовых решений. На текущий момент существует не так много сторонних инструментов для управления умными лампами, тем более на языке Java.

Главный минус библиотеки Yapi состоит в том, что ни одной его версии не присутствует в репозитории Maven, так что перед компиляцией кода проекта требуется вручную установить Yapi в локальный репозиторий (вся установка описана в README файле в репозитории).

Алгоритм анализа изображения


Принцип работы динамического освещения будет основан на периодическом цветовом анализе текущего кадра.

В результате этапа проб и ошибок был разработан следующий принцип анализа картинки:

С указанной периодичностью программа делает скриншот медиаплеера и получает объект класса BufferedImage. Далее, самым быстрым встроенным алгоритмом, размер исходного изображения изменяется до размера 20x20 пикселей.

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

Далее алгоритм разбивает получившееся изображения на четыре «базовые» зоны (левая верхняя, левая нижняя и т.д.) размером 10x10 пикселей.


«Базовые» зоны

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

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

Благодаря четырем получившимся значениям мы можем:

  • вычислить средний цвет 5 следующих зон: правой, левой, верхней, нижней и центральной зоны экрана (реализовано через вычисление среднего арифметического цвета включенных в искомую область «базовых» зон);
  • вычислить среднюю яркость выбранной зоны в процентах по формуле:

    $(r * 0.2126 + g * 0.7152 + b * 0.0722) / 255 * 100$

    где r, g, b – красная/зеленая/синяя составляющие цвета
  • вычислить среднюю температуру цвета в процентах по придуманной на коленке формуле:

    $$display$$\begin{equation*} \begin{cases} 0, r\le b, \\ (r-b) / 255 * 100, r > b \end{cases} \end{equation*}$$display$$

    где r, b – красная/синяя составляющие цвета

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

Весь код по обработке изображения вмещается в один класс ImageHandler:

public class ImageHandler {
    private static List<ScreenArea> mainAreas = Arrays.asList(ScreenArea.TOP_LEFT, ScreenArea.TOP_RIGHT, ScreenArea.BOTTOM_LEFT, ScreenArea.BOTTOM_RIGHT);
    private static int scaledWidth = 20;
    private static int scaledHeight = 20;
    private static int scaledWidthCenter = scaledWidth / 2;
    private static int scaledHeightCenter = scaledHeight / 2;
    private Map<ScreenArea, Integer> screenData;
    private LightConfig config;

    //получаем массив с необходимыми данными для определения размеров зоны
    private int[] getDimensions(ScreenArea area) {
        int[] dimensions = new int[4];
        if (!mainAreas.contains(area)) {
            return dimensions;
        }
        String name = area.name().toLowerCase();
        dimensions[0] = (name.contains("left")) ? 0 : scaledWidthCenter;
        dimensions[1] = (name.contains("top")) ? 0 : scaledHeightCenter;
        dimensions[2] = scaledWidthCenter;
        dimensions[3] = scaledHeightCenter;
        return dimensions;
    }

    //возвращает изображение с указанными размерами
    private BufferedImage getScaledImage(BufferedImage image, int width, int height) {
        Image tmp = image.getScaledInstance(width, height, Image.SCALE_FAST);
        BufferedImage scaledImage = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);

        Graphics2D g2d = scaledImage.createGraphics();
        g2d.drawImage(tmp, 0, 0, null);
        g2d.dispose();
        return scaledImage;
    }

    //обработка изображения, включает изменение размера, разбиение на зоны, вычисление среднего цвета
    private void proceedImage(BufferedImage image) {
        BufferedImage scaledImage = getScaledImage(image, scaledWidth, scaledHeight);

        screenData = new HashMap<>();
        mainAreas.forEach(area -> {
            int[] dimensions = getDimensions(area);
            BufferedImage subImage = scaledImage.getSubimage(dimensions[0], dimensions[1], dimensions[2], dimensions[3]);

            int average = IntStream.range(0, dimensions[3])
                    .flatMap(row -> IntStream.range(0, dimensions[2]).map(col -> subImage.getRGB(col, row))).boxed()
                    .reduce(new ColorAveragerer(), (t, u) -> {
                        t.accept(u);
                        return t;
                    }, (t, u) -> {
                        t.combine(u);
                        return t;
                    }).average();

            screenData.put(area, average);
        });
    }

    public ImageHandler(BufferedImage image, LightConfig config) {
        this.config = config;
        proceedImage(image);
    }

    //получение данных об изображении по зоне и типу, флаг considerRate определяет учет коэффициентов(о них далее в посте)
    public int getValue(ScreenArea area, Feature feature, Boolean considerRate) {
        Integer intValue = screenData.get(area);
        if (intValue != null) {
            Color color = new Color(intValue);
            if (feature == Feature.COLOR) {
                return color.getRGB();
            } else if (feature == Feature.BRIGHTNESS || feature == Feature.TEMPERATURE) {
                int value = (feature == Feature.BRIGHTNESS) ? getBrightness(color) : getTemperature(color);
                double rate = (feature == Feature.BRIGHTNESS) ? config.getBrightnessRate() : config.getTemperatureRate();
                value = (value < 0) ? 0 : value;
                if (considerRate) {
                    value = 10 + (int) (value * rate);
                }
                return (value > 100) ? 100 : value;
            } else {
                return 0;
            }
        } else {
            calculateArea(area);
            return getValue(area, feature, considerRate);
        }
    }
   
    //вычисление яркости цвета по формуле
    private int getBrightness(Color color) {
        return (int) ((color.getRed() * 0.2126f + color.getGreen() * 0.7152f + color.getBlue() * 0.0722f) / 255 * 100);
    }

    //вычисление температуры цвета по формуле
    private int getTemperature(Color color) {
        return (int) ((float) (color.getRed() - color.getBlue()) / 255 * 100);
    }

    //ленивое вычисление не "базовых" зон
    private void calculateArea(ScreenArea area) {
        int value = 0;
        switch (area) {
            case TOP:
                value = getAverage(ScreenArea.TOP_LEFT, ScreenArea.TOP_RIGHT);
                break;
            case BOTTOM:
                value = getAverage(ScreenArea.BOTTOM_LEFT, ScreenArea.BOTTOM_RIGHT);
                break;
            case LEFT:
                value = getAverage(ScreenArea.BOTTOM_LEFT, ScreenArea.TOP_LEFT);
                break;
            case RIGHT:
                value = getAverage(ScreenArea.BOTTOM_RIGHT, ScreenArea.TOP_RIGHT);
                break;
            case WHOLE_SCREEN:
                value = getAverage(mainAreas.toArray(new ScreenArea[0]));
                break;
        }
        screenData.put(area, value);
    }

    //возвращает среднее арифметическое цвета между указанными зонами
    private int getAverage(ScreenArea... areas) {
        return Arrays.stream(areas).map(color -> screenData.get(color))
                .reduce(new ColorAveragerer(), (t, u) -> {
                    t.accept(u);
                    return t;
                }, (t, u) -> {
                    t.combine(u);
                    return t;
                }).average();
    }

    //получение массива rgb из int-вого значения цвета
    public static int[] getRgbArray(int color) {
        int[] rgb = new int[3];
        rgb[0] = (color >>> 16) & 0xFF;
        rgb[1] = (color >>> 8) & 0xFF;
        rgb[2] = (color >>> 0) & 0xFF;
        return rgb;
    }

    //получение int-вого значения цвета из массива rgb
    public static int getRgbInt(int[] pixel) {
        int value = ((255 & 0xFF) << 24) |
                ((pixel[0] & 0xFF) << 16) |
                ((pixel[1] & 0xFF) << 8) |
                ((pixel[2] & 0xFF) << 0);
        return value;
    }

   //вложенный класс для определения среднего значения цвета с помощью stream API
    private class ColorAveragerer {
        private int[] total = new int[]{0, 0, 0};
        private int count = 0;

        private ColorAveragerer() {
        }

        private int average() {
            int[] rgb = new int[3];
            for (int it = 0; it < total.length; it++) {
                rgb[it] = total[it] / count;
            }

            return count > 0 ? getRgbInt(rgb) : 0;
        }

        private void accept(int i) {
            int[] rgb = getRgbArray(i);
            for (int it = 0; it < total.length; it++) {
                total[it] += rgb[it];
            }
            count++;
        }

        private void combine(ColorAveragerer other) {
            for (int it = 0; it < total.length; it++) {
                total[it] += other.total[it];
            }
            count += other.count;
        }
    }
}


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

Cравнение с другим методом анализа


Вы можете спросить: «Почему бы просто не уменьшить изображение до размера 2x2 пикселя и не считать полученные значения?».
Ответ будет таков: «Исходя из проведенных мною опытов, алгоритм определения среднего цвета через уменьшение размера изображения (или его зон) проявил себя менее стабильно и менее достоверно (особенно при анализе темных участков изображения), чем алгоритм, основанный на определении среднего арифметического всех пикселей».

Были испробованы несколько методов изменения размера изображения. Можно было воспользоваться библиотекой openCV для более серьезной работы с изображением, однако я посчитал это оверинженерингом для данной задачи. Для сравнения, ниже представлен пример определения цвета с помощью встроенного быстрого скейлинга класса BufferedImage и вычисления среднего арифметического цвета. Думаю комментарии излишни.



Конфигурирование


На текущий момент программа конфигурируется при помощи файла формата json. В качестве библиотеки для парсинга конфигурационного файла использована JSON.simple.

Json файл необходимо назвать «config.json» и положить в одну папку с программой для автоматического определения конфигурации, иначе при включении функции адаптивной яркости программа предложит самостоятельно указать файл конфигурации, открыв окно выбора файлов. В файле необходимо указать ip адреса осветительных устройств, «отслеживаемые» зоны изображения для каждого девайса, коэффициенты яркости и температуры цвета, либо период их автоматической установки (о чем будет описано в следующем пункте). Правила заполнения json файла описаны в README файле проекта.


Все изменения в интерфейсе (кнопка лампочки). При нажатии кнопки применится доступный файл конфигурации или откроется окно его выбора

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

Автоматическое выставление коэффициентов


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

$l = 1 + x / 100$

где x – текущее значение яркости комнаты в процентах.

Включается эта функция с помощью прописывания специального тега в конфигурационном файле.

Пример работы функционала



Заключение


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

Всем спасибо за внимание!

P.S. Буду рад любым дополнениям, замечаниям и указанием на допущенные ошибки.