Привет! Я Аня, и очень люблю писать интересные интерености под E-commerce.

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

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

Для нетерпеливых - вот ссылка на Github

ВАЖНО! ПРОЧИТАТЬ ПЕРЕД ДАЛЬНЕЙШИМ ЧТЕНИЕМ СТАТЬИ!!!!


1) Мой код не претендует на идеальный.

2) Библиотека не является абсолютно готовым продуктом, может иметь ошибки в работе, не идеальный накладываемый эффект, а так же некоторые нюасы в Safari. Кому нужен идеально работающий продукт - можно купить за много денег :)

3) Я буду рада почитать ваши конструктивные идеи/рекомендации/предложения в комментариях.

4) Код может содержать пометки с TODO - это нормально.

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

  1. Введение

  2. Структура проекта: Constants

  3. Структура проекта: Lib - The Core of The Library

  4. Структура проекта: Utility

  5. Структура проекта: Effect

  6. Как создать маску для наложения очков

  7. Как подключить библиотеку

  8. Заключение

Введение:

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

Основные функции

  • Поток с камеры в реальном времени: Библиотека использует веб-камеру пользователя для захвата видеопотока лица в реальном времени, позволяя ему видеть себя в реальном времени, примеряя различную косметику и аксессуары.

  • Применение эффектов к статическому изображению: Библиотека поддерживает применение макияжа и аксессуаров к статическим изображениям.

Доступные эффекты

Блеск для губ, Карандаш для губ, Помада, Помада с шиммером, Матовая помада, Цвет бровей, Подводка для глаз, Тушь, Карандаш для глаз (Каял), Тени для век сатиновые, Тени для век матовые, Тени для век с шиммером, Тональный крем сатиновый, Тональный крем матовый, Консилер, Контур/Бронзер, Очки

Системные требования

  • Доступная камера (для режима камеры): Убедитесь, что камера доступна.

  • SSL-сертификат: WebRTC не работает без протокола HTTPS.

  • Поддержка браузеров

Браузер

Минимальные требования к браузеру

Chrome

52+

Firefox

35+

Internet Explorer

Н/Д*

Opera

39+

Safari

11+

* Internet Explorer не поддерживается полностью из-за отсутствия поддержки некоторых современных веб-функций и WebRTC. Рекомендуется использовать современный браузер для лучшей совместимости и безопасности.

Краткий обзор проекта

  • _documentation: документация.

  • Constants: Основные константы, доступные для этой библиотеки.

  • Effect: Здесь применяются различные эффекты.

  • Lib: Используется для хранения «ядра» этого приложения для определения параметров лица.

  • Utility: Различные вспомогательные функции.

  • face.png: Демо-лицо для «режима изображения». Изображение было взято из Интернета из открытых источников.

  • glasses.png: Демо-маска пары очков. Смотрите ниже, как её создать.

  • main.js: Скрипт, используемый для управления библиотекой.

  • main.html: Демо файл этой библиотеки.

Отладка на локальной машине

WebRTC не работает без SSL-сертификата. Если вам нужно отладить библиотеку, пожалуйста, установите флаг в Google Chrome. Откройте ссылку ниже и введите свой локальный домен в поле "Insecure origins treated as secure" (Небезопасные источники, рассматриваемые как безопасные).

Пример для GoogleChrome:

   chrome://flags/#unsafely-treat-insecure-origin-as-secure

Браузер Mozilla:

- Откройте в браузере -> about:config

- Установите значение "true" для media.devices.insecure.enabled и media.getusermedia.insecure.enabled

Структура проекта: Constants

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

Пример файла EffectConstants.js
export const EFFECT_BROWS_COLOR = 'BrowsColor';export const EFFECT_LIPSTICK = 'Lipstick';export const EFFECT_MATTE_LIPSTICK = 'MatteLipstick';export const EFFECT_MASCARA = 'Mascara';export const EFFECT_EYELINER = 'Eyeliner';export const EFFECT_KAJAL = 'Kajal';export const EFFECT_EYEGLASSES = 'Eyeglasses';export const EFFECT_LIPLINER = 'LipLiner';export const EFFECT_LIP_GLOSS = 'LipGloss';export const EFFECT_LIPSTICK_SHIMMER = 'LipstickShimmer';export const EFFECT_FOUNDATION_SATIN = 'FoundationSatin';export const EFFECT_FOUNDATION_MATTE = 'FoundationMatte';export const EFFECT_CONCEALER = 'Concealer';export const EFFECT_CONTOUR = 'Contour';export const EFFECT_EYESHADOW_SATIN = 'EyeShadowSatin';export const EFFECT_EYESHADOW_MATTE = 'EyeShadowMatte';export const EFFECT_EYESHADOW_SHIMMER = 'EyeShadowShimmer';Object.defineProperty(window, 'EFFECT_EYESHADOW_SHIMMER', {
    value: 'EyeShadowShimmer',
    writable: false,
    configurable: false
});Object.defineProperty(window, 'EFFECT_EYESHADOW_MATTE', {
    value: 'EyeShadowMatte',
    writable: false,
    configurable: false
});Object.defineProperty(window, 'EFFECT_EYESHADOW_SATIN', {
    value: 'EyeShadowSatin',
    writable: false,
    configurable: false
});Object.defineProperty(window, 'EFFECT_CONTOUR', {
    value: 'Contour',
    writable: false,
    configurable: false
});Object.defineProperty(window, 'EFFECT_CONCEALER', {
    value: 'Concealer',
    writable: false,
    configurable: false
});Object.defineProperty(window, 'EFFECT_FOUNDATION_MATTE', {
    value: 'FoundationMatte',
    writable: false,
    configurable: false
});Object.defineProperty(window, 'EFFECT_FOUNDATION_SATIN', {
    value: 'FoundationSatin',
    writable: false,
    configurable: false
});Object.defineProperty(window, 'EFFECT_BROWS_COLOR', {
    value: 'BrowsColor',
    writable: false,
    configurable: false
});Object.defineProperty(window, 'EFFECT_MASCARA', {
    value: 'Mascara',
    writable: false,
    configurable: false
});Object.defineProperty(window, 'EFFECT_LIPSTICK', {
    value: 'Lipstick',
    writable: false,
    configurable: false
});Object.defineProperty(window, 'EFFECT_MATTE_LIPSTICK', {
    value: 'MatteLipstick',
    writable: false,
    configurable: false
});Object.defineProperty(window, 'EFFECT_EYELINER', {
    value: 'Eyeliner',
    writable: false,
    configurable: false
});Object.defineProperty(window, 'EFFECT_KAJAL', {
    value: 'Kajal',
    writable: false,
    configurable: false
});Object.defineProperty(window, 'EFFECT_EYEGLASSES', {
    value: 'Eyeglasses',
    writable: false,
    configurable: false

});Object.defineProperty(window, 'EFFECT_LIPLINER', {
    value: 'LipLiner',
    writable: false,
    configurable: false
});Object.defineProperty(window, 'EFFECT_LIP_GLOSS', {
    value: 'LipGloss',
    writable: false,
    configurable: false
});Object.defineProperty(window, 'EFFECT_LIPSTICK_SHIMMER', {
    value: 'LipstickShimmer',
    writable: false,
    configurable: false
});

Структура проекта: Lib - The Core of The Library

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

Определение точек на лице выглядит вот так:

взято с официального сайта https://ai.google.dev/
взято с официального сайта https://ai.google.dev/

Каждая точка имеет свои координаты в плоскости. Для того, чтобы закрасить определенную область, я выбирала координаты, замыкала их в фигуру, а после - применяла эффекты с наложением цвета/аксессуаров. Тут можно посмотреть полную документацию про FaceLandmarks.

Теперь немного детальнее, в проекте это все находится в:

- Lib
  - Mediapipe
      - face_mesh
      - vision_task

На момент разработки, библиотека использует две реализации: стабильную версию Face Mesh и экспериментальную Vision Task, однако предпочтение отдано первой из-за большей надёжности. Каждый движок сопровождается своим Processor — интерфейсом, отвечающим за обработку изображения или видео, масштабирование холста, валидацию HTML-элементов и запуск цикла отображения макияжа в реальном времени.

Важно! При переключении между движками, имейте ввиду, что FaceMesh не поддерживает работу с волосами и на сегодняшний день является устаревшим.

FaceMeshProcessor
import FaceMeshEngine from './engine/FaceMeshEngine.js';

/**
 * Processor of FaceMesh engine
 * @type {{processVideo: ((function(*, *, *): Promise<boolean>)|*), processImage: (function(*, *, *): Promise<boolean>)}}
 */
const FaceMeshProcessor = (function () {

        let isAnimating = false;
        let intervalId = null;

        /**
         * Validate that given object is <img> element.
         * (can be get by document.getElementById("ID_STRING"))
         *
         * @param imageHtmlObject
         */
        function validateImageHtmlObject(imageHtmlObject) {
            if (imageHtmlObject.tagName.toLowerCase() !== 'img') {
                throw new Error("Can not process image. The given object doesn't represent img tag");
            }
        }

        /**
         * Validate that given object is <video> element.
         * (can be get by document.getElementById("ID_STRING"))
         *
         * @param videoHtmlObject
         */
        function validateVideoHtmlObject(videoHtmlObject) {
            if (videoHtmlObject.tagName.toLowerCase() !== 'video') {
                throw new Error("Can not process video. The given object doesn't represent video tag");
            }
        }

        /**
         * Validate that given object is <canvas> element.
         * (can be get by document.getElementById("ID_STRING"))
         *
         * @param canvasHtmlObject
         */
        function validateCanvasHtmlObject(canvasHtmlObject) {
            if (canvasHtmlObject.tagName.toLowerCase() !== 'canvas') {
                throw new Error("Can not process video. The given object doesn't represent canvas tag");
            }
        }

        /**
         * Process video with requested effect
         *
         * Where:
         *  sourceVideoHtmlObject - <video> html object
         *  resultCanvasHTMLObject - canvas where to show the resulting output
         *  effectObject - object with effect settings (see FaceMesh documentation to get more info)
         *
         * @param sourceVideoHtmlObject
         * @param resultCanvasHTMLObject
         * @param effectObject
         * @returns {Promise<boolean>}
         */
        async function processVideo (sourceVideoHtmlObject, resultCanvasHTMLObject, effectObject) {

            validateCanvasHtmlObject(resultCanvasHTMLObject);

            async function drawResults() {
                validateVideoHtmlObject(sourceVideoHtmlObject);

                let resultCanvasContext = resultCanvasHTMLObject.getContext('2d');
                let width = sourceVideoHtmlObject.clientWidth;
                let height = sourceVideoHtmlObject.clientHeight;

                // make result canvas the same size as source video
                resultCanvasHTMLObject.width = width;
                resultCanvasHTMLObject.height = height;

                resultCanvasContext.drawImage(sourceVideoHtmlObject, 0, 0, width, height);

                await FaceMeshEngine.process(
                    sourceVideoHtmlObject,
                    resultCanvasHTMLObject,
                    effectObject
                );
            }

            intervalId = setInterval(async () => {
                if (!isAnimating) {
                    isAnimating = true;
                    await drawResults();
                    isAnimating = false;
                }
            }, 10);
        }

        /**
         * Process image with a requested effect
         *
         * Where:
         *  SourceImageHtmlObject - <img> html object
         *  resultCanvasHTMLObject - canvas where to show the resulting output
         *  effectObject - object with effect settings (see FaceMesh documentation to get more info)
         *
         * @param sourceImageHtmlObject
         * @param resultCanvasHTMLObject
         * @param effectObject
         * @returns {Promise<boolean>}
         */
        async function processImage (sourceImageHtmlObject, resultCanvasHTMLObject, effectObject) {
            validateImageHtmlObject(sourceImageHtmlObject);
            validateCanvasHtmlObject(resultCanvasHTMLObject);

            // make result canvas the same size as source image
            resultCanvasHTMLObject.width = sourceImageHtmlObject.width;
            resultCanvasHTMLObject.height = sourceImageHtmlObject.height;

            await FaceMeshEngine.process(
                sourceImageHtmlObject,
                resultCanvasHTMLObject,
                effectObject
            );
        }

        return { // Public Area

            /**
             * Where processedElementHtmlObject can be either <img> or <video> html object
             * @param processedElementHtmlObject
             * @param resultCanvasHTMLObject
             * @param effectObject
             * @returns {Promise<void>}
             */
            process: async function(processedElementHtmlObject, resultCanvasHTMLObject, effectObject) {
                await this.terminate();

                if (processedElementHtmlObject.tagName.toLowerCase() === 'video') {
                    await processVideo(processedElementHtmlObject, resultCanvasHTMLObject, effectObject);
                } else if (processedElementHtmlObject.tagName.toLowerCase() === 'img') {
                    await processImage(processedElementHtmlObject, resultCanvasHTMLObject, effectObject);
                } else {
                    this.terminate();
                    throw new Error("Invalid source type. Can process img or video only");
                }
            },

            /**
             * Stop detection
             */
            terminate: function () {
                clearInterval(intervalId);
                isAnimating = false;
            }
        };
    }
)();

export default FaceMeshProcessor;
VisionTaskProcessor
import VisionTaskEngine from "./engine/VisionTaskEngine.js";
import * as Constants from "../../../Constants/EffectConstants.js";

/**
 * @type {{launch: VisionTaskProcessor.launch, terminate: VisionTaskProcessor.terminate}}
 */
const VisionTaskProcessor = (function () {

        let isAnimating = false;
        let intervalId = null;

        /**
         * Validate that given object is <img> element.
         * (can be get by document.getElementById("ID_STRING"))
         *
         * @param imageHtmlObject
         */
        function validateImageHtmlObject(imageHtmlObject) {
            if (imageHtmlObject.tagName.toLowerCase() !== 'img') {
                throw new Error("Can not process image. The given object doesn't represent img tag");
            }
        }

        /**
         * Validate that given object is <video> element.
         * (can be get by document.getElementById("ID_STRING"))
         *
         * @param videoHtmlObject
         */
        function validateVideoHtmlObject(videoHtmlObject) {
            if (videoHtmlObject.tagName.toLowerCase() !== 'video') {
                throw new Error("Can not process video. The given object doesn't represent video tag");
            }
        }

        /**
         * Validate that given object is <canvas> element.
         * (can be get by document.getElementById("ID_STRING"))
         *
         * @param canvasHtmlObject
         */
        function validateCanvasHtmlObject(canvasHtmlObject) {
            if (canvasHtmlObject.tagName.toLowerCase() !== 'canvas') {
                throw new Error("Can not process video. The given object doesn't represent canvas tag");
            }
        }

        /**
         * Process video with requested effect
         *
         * Where:
         *  sourceVideoElementHTMLObject - <video> html object
         *  resultCanvasHTMLObject - canvas where to show the resulting output
         *  effectObject - object with effect settings (see FaceMesh documentation to get more info)
         *
         * @param sourceVideoElementHTMLObject
         * @param resultCanvasHTMLObject
         * @param effectObject
         * @returns {Promise<void>}
         */
        async function processVideo (sourceVideoElementHTMLObject, resultCanvasHTMLObject, effectObject) {

            validateVideoHtmlObject(sourceVideoElementHTMLObject);
            validateCanvasHtmlObject(resultCanvasHTMLObject);

            async function drawResults() {

                let landmarksDetected = false;

                let width = sourceVideoElementHTMLObject.clientWidth;
                let height = sourceVideoElementHTMLObject.clientHeight;

                // make result canvas the same size as source image
                resultCanvasHTMLObject.width = width;
                resultCanvasHTMLObject.height = height;

                if (effectObject.effect === Constants.EFFECT_HAIR_COLOR) {
                    landmarksDetected = await VisionTaskEngine.processHairVideo(
                        sourceVideoElementHTMLObject,
                        resultCanvasHTMLObject,
                        effectObject
                    );
                } else {
                    await VisionTaskEngine.processFaceVideo(
                        sourceVideoElementHTMLObject,
                        resultCanvasHTMLObject,
                        effectObject
                    );
                }
            }

            intervalId = setInterval(async () => {
                if (!isAnimating) {
                    isAnimating = true;
                    await drawResults();
                    isAnimating = false;
                }
            }, 10);
        }

        /**
         * Process image with a requested effect
         *
         * Where:
         *  sourceImageHtmlObject - <img> html object
         *  resultCanvasHTMLObject - canvas where to show the resulting output
         *  effectObject - object with effect settings (see FaceMesh documentation to get more info)
         *
         * @param sourceImageHtmlObject
         * @param resultCanvasHTMLObject
         * @param effectObject
         * @returns {Promise<boolean>}
         */
        async function processImage (sourceImageHtmlObject, resultCanvasHTMLObject, effectObject) {
            validateImageHtmlObject(sourceImageHtmlObject);
            validateCanvasHtmlObject(resultCanvasHTMLObject);

            // make result canvas the same size as source image
            resultCanvasHTMLObject.width = sourceImageHtmlObject.width;
            resultCanvasHTMLObject.height = sourceImageHtmlObject.height;

            if (effectObject.effect === Constants.EFFECT_HAIR_COLOR) {
                return await VisionTaskEngine.processHairImage(
                    sourceImageHtmlObject,
                    resultCanvasHTMLObject,
                    effectObject
                );
            } else {
                return await VisionTaskEngine.processFaceImage(
                    sourceImageHtmlObject,
                    resultCanvasHTMLObject,
                    effectObject
                );
            }
        }

        return { // Public Area

            /**
             * Where processedElementHtmlObject can be either <img> or <video> html object
             * @param processedElementHtmlObject
             * @param resultCanvasHTMLObject
             * @param effectObject
             * @returns {Promise<void>}
             */
            process: async function(processedElementHtmlObject, resultCanvasHTMLObject, effectObject) {
                this.terminate();

                if (processedElementHtmlObject.tagName.toLowerCase() === 'video') {
                    await processVideo(processedElementHtmlObject, resultCanvasHTMLObject, effectObject);
                } else if (processedElementHtmlObject.tagName.toLowerCase() === 'img') {
                    await processImage(processedElementHtmlObject, resultCanvasHTMLObject, effectObject);
                } else {
                    this.terminate();
                    throw new Error("Invalid source type. Can process img or video only");
                }
            },

            /**
             * Stop detection
             */
            terminate: function () {
                clearInterval(intervalId);
                isAnimating = false;
            }
        };
    }
)();

export default VisionTaskProcessor;

Т.к я большее внимание уделила FaceMesh, и на тот момент он был более стабильный в работе, то здесь и далее я буду описывать структуру для FaceMesh. Vision Task имеет аналогичную структуру.

Давайте посмотрим еще раз на структуру движка:

- Lib
  - Mediapipe
      - face_mesh
        - core
        - detector
        - engine
        Processor file.js
        README.md

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

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

  • Detector - отвечает за определение координат ключевых зон лица, необходимых для точного нанесения виртуального макияжа. Каждый детектор в этом модуле — это логически выделенный компонент, специализирующийся на конкретной области лица, например, бровях, губах или глазах. Он получает на вход landmarks, возвращаемые FaceMesh, и на основе этих данных рассчитывает контур нужной области с помощью утилит из core. Этот слой изолирует логику извлечения координат, позволяя чётко разделить обработку геометрии лица и визуальное применение эффектов.

Пример BrowsDetector
import CoordinatesUtility from "../core/coordinates_utils.js";

/**
 * Define brows and return object of detected coordinates
 * @type {{apply: BrowsColorEffect.apply}}
 */
const BrowsDetector = (function () {

    /**
     * Return eye brow contour point coordinates
     *
     * @param landmarks
     * @param eyebrowPoints
     * @param canvas
     * @returns {*}
     */
    function getEyebrowContourCoordinates(landmarks, eyebrowPoints, canvas) {
        let bottomContour = eyebrowPoints.slice(1, 4);
        let topContour = eyebrowPoints.slice(4).reverse();
        let contour = bottomContour.concat(
            [[bottomContour.slice(-1)[0][1], topContour[0][1]]],
            topContour,
            [[topContour.slice(-1)[0][0], bottomContour[0][1]]],
        );
        return contour.map(point => {
            return CoordinatesUtility.getPointCoordinates(
                landmarks, point[0], canvas.width, canvas.height
            );
        });
    }

    return { // Public Area
        /**
         * Detect contours coordinates of brows and return them
         * @param resultCanvasElement
         * @param landmarks
         * @param leftEyebrowPoints
         * @param rightEyebrowPoints
         * @returns {{leftBrowContourCoordinates: *, rightBrowContourCoordinates: *}}
         */
        detect: function (resultCanvasElement, landmarks, leftEyebrowPoints, rightEyebrowPoints) {

            let rightBrowContourCoordinates = getEyebrowContourCoordinates(
                landmarks, rightEyebrowPoints, resultCanvasElement
            );

            let leftBrowContourCoordinates = getEyebrowContourCoordinates(
                landmarks, leftEyebrowPoints, resultCanvasElement
            );

            return {
                "rightBrowContourCoordinates": rightBrowContourCoordinates,
                "leftBrowContourCoordinates": leftBrowContourCoordinates
            }
        }
    };
})();

export default BrowsDetector;
  • Engine - отвечает за распознавание лица на изображениях или видео с помощью библиотеки MediaPipe FaceMesh и нанесение выбранных визуальных эффектов, таких как макияж или аксессуары.

ВАЖНО! При разворачивании, в этом файле обратите внимание на TODO - убедитесь, что вы верно настроили пути, иначе файлы не подгрузятся!

Пример FaceMeshEngine.js
// Detectors
import BrowsDetector from '../detector/BrowsDetector.js';
import EyesDetector from '../detector/EyesDetector.js';
import LipsDetector from '../detector/LipsDetector.js';
import JawDetector from '../detector/JawDetector.js';
import FaceDetector from '../detector/FaceDetector.js';

// Makeup effects
import LipstickEffect from '../../../../Effect/Makeup/Lipstick.js';
import LipstickMatteEffect from '../../../../Effect/Makeup/LipstickMatte.js';
import BrowsColorEffect from '../../../../Effect/Makeup/BrowsColor.js';
import EyelinerEffect from '../../../../Effect/Makeup/Eyeliner.js';
import LipLinerEffect from '../../../../Effect/Makeup/LipLiner.js';
import LipGlossEffect from '../../../../Effect/Makeup/LipGloss.js';
import LipstickShimmerEffect from "../../../../Effect/Makeup/LipstickShimmer.js";
import KajalEffect from "../../../../Effect/Makeup/Kajal.js";
import MascaraEffect from "../../../../Effect/Makeup/Mascara.js";
import FoundationSatinEffect from "../../../../Effect/Makeup/FoundationSatin.js";
import FoundationMatteEffect from "../../../../Effect/Makeup/FoundationMatte.js";
import ConcealerEffect from "../../../../Effect/Makeup/Concealer.js";
import ContourEffect from "../../../../Effect/Makeup/Contour.js";
import EyeShadowSatin from "../../../../Effect/Makeup/EyeShadowSatin.js";
import EyeShadowMatte from "../../../../Effect/Makeup/EyeShadowMatte.js";
import EyeShadowShimmer from "../../../../Effect/Makeup/EyeShadowShimmer.js";

// Accessories effects
import EyeGlassesEffect from '../../../../Effect/Accessories/EyeGlassesEffect.js';

// Constants Area
import * as Constants from '../../../../Constants/EffectConstants.js';

/**
 * FaceMeshEngine
 * @type {{processVideo: ((function(*, *, *): Promise<void>)|*)}}
 */
const FaceMeshEngine = (function () {

        var faceMesh = null;
        var initializationPromise = null; // Store the promise for initialization
        var currentMode = null;

        /**
         * Initialize facemesh library here
         */
        function initialize() {
            if (initializationPromise) {
                return initializationPromise; // Return existing promise if initialization is already in progress
            }

            initializationPromise = new Promise((resolve, reject) => {
                Promise.all([
                    import('../core/camera_utils.js'),
                    import('../core/control_utils.js'),
                    import('../core/drawing_utils.js'),
                    import('../core/face-mesh.js'),
                ])
                    .then(async ([
                        camera_utils,
                        control_utils,
                        drawing_utils,
                        face_mesh,
                    ]) => {

                        faceMesh = await new FaceMesh({
                            locateFile: (file) => {
                                return `/mirror/Lib/Mediapipe/face_mesh/core/wasm/${file}`; //TODO correct the path of images
                            }
                        });

                        faceMesh.setOptions({
                            maxNumFaces: 1,
                            refineLandmarks: true,
                            minDetectionConfidence: 0.5,
                            minTrackingConfidence: 0.5
                        });

                        resolve(); // Resolve the promise once initialization is complete
                    })
                    .catch(error => {
                        reject(error); // Reject the promise if initialization fails
                    });
            });

            return initializationPromise;
        }

        /**
         * Apply effect by a given result landmarks and effect object
         * @param landmarks
         * @param effectObject
         * @param resultCanvasHTMLObject
         */
        function applyEffect(landmarks, effectObject, resultCanvasHTMLObject) {
            let detectionData = null;

            switch (effectObject.effect) {

                case Constants.EFFECT_BROWS_COLOR:
                    detectionData = BrowsDetector.detect(
                        resultCanvasHTMLObject,
                        landmarks,
                        FACEMESH_LEFT_EYEBROW,
                        FACEMESH_RIGHT_EYEBROW
                    );
                    BrowsColorEffect.apply(resultCanvasHTMLObject, detectionData, effectObject);
                    break;

                case Constants.EFFECT_LIPSTICK:
                    detectionData = LipsDetector.detect(resultCanvasHTMLObject, landmarks, FACEMESH_LIPS);
                    LipstickEffect.apply(resultCanvasHTMLObject, detectionData, effectObject);
                    break;

                case Constants.EFFECT_LIPLINER:
                    detectionData = LipsDetector.detect(resultCanvasHTMLObject, landmarks, FACEMESH_LIPS);
                    LipLinerEffect.apply(resultCanvasHTMLObject, detectionData, effectObject);
                    break;

                case Constants.EFFECT_LIP_GLOSS:
                    detectionData = LipsDetector.detect(resultCanvasHTMLObject, landmarks, FACEMESH_LIPS);
                    LipGlossEffect.apply(resultCanvasHTMLObject, detectionData, effectObject);
                    break;

                case Constants.EFFECT_LIPSTICK_SHIMMER:
                    detectionData = LipsDetector.detect(resultCanvasHTMLObject, landmarks, FACEMESH_LIPS);
                    LipstickShimmerEffect.apply(resultCanvasHTMLObject, detectionData, effectObject);
                    break;

                case Constants.EFFECT_MATTE_LIPSTICK:
                    detectionData = LipsDetector.detect(resultCanvasHTMLObject, landmarks, FACEMESH_LIPS);
                    LipstickMatteEffect.apply(resultCanvasHTMLObject, detectionData, effectObject);
                    break;

                case Constants.EFFECT_EYELINER:
                    detectionData = EyesDetector.detect(
                        resultCanvasHTMLObject,
                        landmarks,
                        FACEMESH_TESSELATION,
                        FACEMESH_LEFT_EYE,
                        FACEMESH_RIGHT_EYE
                    );
                    EyelinerEffect.apply(resultCanvasHTMLObject, detectionData, effectObject);
                    break;

                case Constants.EFFECT_EYESHADOW_SATIN:
                    detectionData = EyesDetector.detect(
                        resultCanvasHTMLObject,
                        landmarks,
                        FACEMESH_TESSELATION,
                        FACEMESH_LEFT_EYE,
                        FACEMESH_RIGHT_EYE
                    );
                    EyeShadowSatin.apply(resultCanvasHTMLObject, detectionData, effectObject);
                    break;

                case Constants.EFFECT_EYESHADOW_MATTE:
                    detectionData = EyesDetector.detect(
                        resultCanvasHTMLObject,
                        landmarks,
                        FACEMESH_TESSELATION,
                        FACEMESH_LEFT_EYE,
                        FACEMESH_RIGHT_EYE
                    );
                    EyeShadowMatte.apply(resultCanvasHTMLObject, detectionData, effectObject);
                    break;

                case Constants.EFFECT_EYESHADOW_SHIMMER:
                    detectionData = EyesDetector.detect(
                        resultCanvasHTMLObject,
                        landmarks,
                        FACEMESH_TESSELATION,
                        FACEMESH_LEFT_EYE,
                        FACEMESH_RIGHT_EYE
                    );
                    EyeShadowShimmer.apply(resultCanvasHTMLObject, detectionData, effectObject);
                    break;

                case Constants.EFFECT_KAJAL:
                    detectionData = EyesDetector.detect(
                        resultCanvasHTMLObject,
                        landmarks,
                        FACEMESH_TESSELATION,
                        FACEMESH_LEFT_EYE,
                        FACEMESH_RIGHT_EYE
                    );
                    KajalEffect.apply(resultCanvasHTMLObject, detectionData, effectObject);
                    break;

                case Constants.EFFECT_MASCARA:
                    detectionData = EyesDetector.detect(
                        resultCanvasHTMLObject,
                        landmarks,
                        FACEMESH_TESSELATION,
                        FACEMESH_LEFT_EYE,
                        FACEMESH_RIGHT_EYE
                    );
                    MascaraEffect.apply(resultCanvasHTMLObject, detectionData, effectObject);
                    break;

                case Constants.EFFECT_EYEGLASSES:
                    detectionData = EyesDetector.detect(
                        resultCanvasHTMLObject,
                        landmarks,
                        FACEMESH_TESSELATION,
                        FACEMESH_LEFT_EYE,
                        FACEMESH_RIGHT_EYE
                    );
                    let jawData = JawDetector.detect(
                        resultCanvasHTMLObject,
                        landmarks,
                        FACEMESH_FACE_OVAL
                    );
                    EyeGlassesEffect.apply(resultCanvasHTMLObject, detectionData, jawData, effectObject);
                    break;

                case Constants.EFFECT_FOUNDATION_SATIN:
                    detectionData = Object.assign(
                        EyesDetector.detect(resultCanvasHTMLObject, landmarks, FACEMESH_TESSELATION, FACEMESH_LEFT_EYE, FACEMESH_RIGHT_EYE),
                        FaceDetector.detect(resultCanvasHTMLObject, landmarks, FACEMESH_FACE_OVAL),
                        BrowsDetector.detect(resultCanvasHTMLObject, landmarks, FACEMESH_LEFT_EYEBROW, FACEMESH_RIGHT_EYEBROW),
                        LipsDetector.detect(resultCanvasHTMLObject, landmarks, FACEMESH_LIPS)
                    );

                    FoundationSatinEffect.apply(resultCanvasHTMLObject, detectionData, effectObject);
                    break;

                case Constants.EFFECT_FOUNDATION_MATTE:
                    detectionData = Object.assign(
                        EyesDetector.detect(resultCanvasHTMLObject, landmarks, FACEMESH_TESSELATION, FACEMESH_LEFT_EYE, FACEMESH_RIGHT_EYE),
                        FaceDetector.detect(resultCanvasHTMLObject, landmarks, FACEMESH_FACE_OVAL),
                        BrowsDetector.detect(resultCanvasHTMLObject, landmarks, FACEMESH_LEFT_EYEBROW, FACEMESH_RIGHT_EYEBROW),
                        LipsDetector.detect(resultCanvasHTMLObject, landmarks, FACEMESH_LIPS)
                    );

                    FoundationMatteEffect.apply(resultCanvasHTMLObject, detectionData, effectObject);
                    break;

                case Constants.EFFECT_CONCEALER:
                    detectionData = EyesDetector.detect(
                        resultCanvasHTMLObject,
                        landmarks,
                        FACEMESH_TESSELATION,
                        FACEMESH_LEFT_EYE,
                        FACEMESH_RIGHT_EYE
                    );

                    ConcealerEffect.apply(resultCanvasHTMLObject, detectionData, effectObject);
                    break;

                case Constants.EFFECT_CONTOUR:
                    detectionData = FaceDetector.detect(resultCanvasHTMLObject, landmarks, FACEMESH_FACE_OVAL);
                    ContourEffect.apply(resultCanvasHTMLObject, detectionData, effectObject);
                    break;

                default:
                    throw new Error('Unknown effect type: ' + effectObject.effect);
                    break;
            }
        }

        return { // Public Area

            /**
             * Process video or image with requested effect
             *
             * Where:
             *  processedElementHTMLObject - it can be either <img> html object or <video> html object
             *  resultCanvasHTMLObject - canvas where to show the resulting output
             *  effectObject - object with effect settings (see FaceMesh documentation to get more info)
             *
             * @param processedElementHTMLObject
             * @param resultCanvasHTMLObject
             * @param effectObject
             * @returns {Promise<boolean>}
             */
            process: async function (processedElementHTMLObject, resultCanvasHTMLObject, effectObject) {
                let landmarksDetected = false;

                if (faceMesh == undefined || faceMesh == null) {
                    await initialize();
                }

                if (currentMode !== processedElementHTMLObject.tagName.toLowerCase()) {
                    currentMode = processedElementHTMLObject.tagName.toLowerCase();
                    await faceMesh.reset();
                }

                faceMesh.onResults(async function (results) {
                    let faceLandmarksPoints = results.multiFaceLandmarks[0];

                    if (faceLandmarksPoints) {
                        let resultCanvasContext = resultCanvasHTMLObject.getContext('2d');

                        // clean result canvas and display captured image from video
                        resultCanvasContext.clearRect(
                            0, 0, processedElementHTMLObject.clientWidth, processedElementHTMLObject.clientHeight
                        );
                        resultCanvasContext.drawImage(
                            results.image, 0, 0, resultCanvasHTMLObject.width, resultCanvasHTMLObject.height
                        );

                        await applyEffect(faceLandmarksPoints, effectObject, resultCanvasHTMLObject);

                        landmarksDetected = true;
                    } else {
                        landmarksDetected = false;
                    }
                });

                await faceMesh.send({image: processedElementHTMLObject});

                return landmarksDetected;
            },
        };
    }
)();

export default FaceMeshEngine;

Структура проекта: Utility

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

Пример структуры:

- Utility
  - ColorUtility.js
  - CoordinatesUtility.js
  - DrawUtility.js
  - SafariUtility.js
  - TextureUtility.js
  • ColorUtility.js - отвечает за работу с цветами: преобразование форматов, вычисление прозрачности, наложение оттенков и другие операции, необходимые для корректного отображения виртуального макияжа. Например, может использоваться для преобразования HEX в RGBA и др.

ColorUtility.js
const ColorUtility= (function () {
    return { // Public Area

        /**
         * Check if the "color" is a valid hexadecimal color code
         * @param colorValue
         * @returns {boolean}
         */
        isHexColor: function (colorValue) {
            const colorRegex = /^#[0-9A-Fa-f]{6}$/;
            return colorRegex.test(colorValue);
        },

        /**
         * Mix color in natural way
         * where factor is domination of desired color from 0 to 1
         * @param desiredColor
         * @param originalColor
         * @param factor
         * @param transparency
         * @returns {{a: number, r: *, b: *, g: *}}
         */
        interpolateColors: function (desiredColor, originalColor, factor, transparency) {
            let p = factor / 100;
            return {
                r: (desiredColor.r - originalColor.r) * p + originalColor.r,
                g: (desiredColor.g - originalColor.g) * p + originalColor.g,
                b: (desiredColor.b - originalColor.b) * p + originalColor.b,
                a: Math.round(transparency * 255)
            };
        },

        /**
         * Make color matte
         * @param {{a, r: number, b: number, g: number}} color
         * @returns {{a, r: number, b: number, g: number}}
         */
        toMatteColor: function (color) {
            // Adjust the saturation and brightness to create a matte effect
            const saturation = 0.9; // Adjust the value as needed
            const brightness = 0.9; // Adjust the value as needed

            const hslColor = this.rgbToHsl(color.r, color.g, color.b);

            // Apply the saturation and brightness modifications
            const modifiedHslColor = {
                h: hslColor.h,
                s: hslColor.s * saturation,
                l: hslColor.l * brightness,
            };

            // Convert the modified HSL color back to RGB color space
            const modifiedRgbColor = this.hslToRgb(modifiedHslColor.h, modifiedHslColor.s, modifiedHslColor.l);

            return {
                r: modifiedRgbColor.r,
                g: modifiedRgbColor.g,
                b: modifiedRgbColor.b,
                a: color.a,
            };
        },

        /**
         * Warning! areaColor and desiredColor must represent an object: {r: number, b: number, g: number}
         * Return average color between applied area (e.g. lips) and desired color
         * Need it to get more natural effect
         *
         * @param {{a, r: number, b: number, g: number}} areaColor
         * @param {{a, r: number, b: number, g: number}} desiredColor
         * @returns {{r: number, b: number, g: number}}
         */
        getAverageColor: function (areaColor, desiredColor) {
            return {
                r: Math.round((areaColor.r + desiredColor.r) / 2),
                g: Math.round((areaColor.g + desiredColor.g) / 2),
                b: Math.round((areaColor.b + desiredColor.b) / 2)
            };
        },

        /**
         * saturationIncrease can be between 0 and 1
         * @param rgbColor
         * @param saturation
         * @returns {*}
         */
        increaseSaturation: function (rgbColor, saturation) {
            if (saturation < 0 || saturation > 1) {
                throw new Error('Invalid saturation value.');
            }

            let hslColor = this.rgbToHsl(rgbColor.r, rgbColor.g, rgbColor.b);

            // Increase the saturation
            hslColor.s += saturation;

            // Ensure saturation is within [0, 1] range
            hslColor.s = Math.max(0, Math.min(1, hslColor.s));

            // Convert HSL back to RGB
            return this.hslToRgb(hslColor.h, hslColor.s, hslColor.l);
        },

        /**
         * Convert hex color to RGB object
         *
         * @param hex
         * @returns {{r: number, b: number, g: number}}
         */
        getRgbFromHex: function (hex) {
            hex = hex.replace('#', '');

            let decimal = parseInt(hex, 16); // Convert hexadecimal to decimal
            let r = (decimal >> 16) & 255; // Extract red component from decimal value
            let g = (decimal >> 8) & 255; // Extract green component from decimal value
            let b = decimal & 255; // Extract blue component from decimal value

            //don't allow 0 value, it won't be applied in mask
            r = r + 1;
            g = g + 1;
            b = b + 1;

            return {r, g, b};
        },

        /**
         * @param r
         * @param g
         * @param b
         * @returns {{s: number, h: number, l: number}}
         */
        rgbToHsl: function (r, g, b) {
            r /= 255;
            g /= 255;
            b /= 255;

            const max = Math.max(r, g, b);
            const min = Math.min(r, g, b);
            let h, s, l = (max + min) / 2;

            if (max === min) {
                h = s = 0; // achromatic
            } else {
                const d = max - min;
                s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
                switch (max) {
                    case r:
                        h = (g - b) / d + (g < b ? 6 : 0);
                        break;
                    case g:
                        h = (b - r) / d + 2;
                        break;
                    case b:
                        h = (r - g) / d + 4;
                        break;
                }
                h /= 6;
            }

            return {
                h: h,
                s: s,
                l: l
            };
        },

        /**
         * @param h
         * @param s
         * @param l
         * @returns {{r: number, b: number, g: number}}
         */
        hslToRgb: function (h, s, l) {
            let r, g, b;

            if (s === 0) {
                r = g = b = l; // achromatic
            } else {
                function hue2rgb(p, q, t) {
                    if (t < 0) t += 1;
                    if (t > 1) t -= 1;
                    if (t < 1 / 6) return p + (q - p) * 6 * t;
                    if (t < 1 / 2) return q;
                    if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
                    return p;
                }

                const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
                const p = 2 * l - q;

                r = hue2rgb(p, q, h + 1 / 3);
                g = hue2rgb(p, q, h);
                b = hue2rgb(p, q, h - 1 / 3);
            }

            return {
                r: Math.round(r * 255),
                g: Math.round(g * 255),
                b: Math.round(b * 255),
            };
        },

        /**
         * Return average color of provided pixels from source canvas image
         * Need to know this color to avoid not natural applied effect
         *
         * @param coordinates
         * @param canvasContext
         * @returns {{r: number, b: number, g: number}}
         */
        getAveragePixelsRgbColor: function (coordinates, canvasContext) {
            let averageColor = {r: 0, g: 0, b: 0};
            let width = 1; // width of the area to capture (in this case, 1 pixel)
            let height = 1; // height of the area to capture (in this case, 1 pixel)

            for (var i = 0; i < coordinates.length; i++) {

                // Capture the image data of the current pixel area
                let imageData = canvasContext.getImageData(
                    coordinates[i].x,
                    coordinates[i].y,
                    width,
                    height
                );

                averageColor.r += imageData.data[0]; // Red component of the pixel color
                averageColor.g += imageData.data[1]; // Green component of the pixel color
                averageColor.b += imageData.data[2]; // Blue component of the pixel color
            }

            // Divide the total color components by the number of areas to get the average color
            // and round the average color components to integers
            let numAreas = coordinates.length;

            averageColor.r = Math.round(averageColor.r / numAreas);
            averageColor.g = Math.round(averageColor.g / numAreas);
            averageColor.b = Math.round(averageColor.b / numAreas);

            return averageColor;
        }
    };
})();

export default ColorUtility;
  • CoordinatesUtility.js - содержит функции для работы с координатами, полученными от движков распознавания лица.

CoordinatesUtility.js
const CoordinatesUtility = (function () {
    return { // Public Area
        /**
         * Return distance in pixels
         * @param point1
         * @param point2
         *
         * where point is:
         * {
         *  x: number,
         *  y: number
         * }
         * @returns {number}
         */
        getDistanceBetweenPoints: function (point1, point2) {
            return Math.sqrt(Math.pow(point1.x - point2.x, 2) + Math.pow(point1.y - point2.y, 2));
        },
    };
})();

export default CoordinatesUtility;
  • DrawUtility.js - предоставляет функции отрисовки на канвасе

DrawUtility.js
const DrawUtility = (function () {
    return { // Public Area
        /**
         * Draw contour by provided coordinates
         *
         * @param canvasContext
         * @param coordinates
         * @param options
         */
        drawContour: function (canvasContext, coordinates, options = {}) {
            if (options.globalCompositeOperation) {
                canvasContext.globalCompositeOperation = options.globalCompositeOperation;
            }

            canvasContext.beginPath();

            for (let i = 0; i < coordinates.length; i++) {
                const point = coordinates[i];
                (i === 0) ? canvasContext.moveTo(point.x, point.y) : canvasContext.lineTo(point.x, point.y);
            }

            canvasContext.closePath();

            if (options.fillStyle) {
                canvasContext.fillStyle = options.fillStyle;
                canvasContext.fill();
            }

            if (options.lineWidth) {
                canvasContext.lineWidth = options.lineWidth;
            }

            if (options.strokeStyle) {
                canvasContext.strokeStyle = options.strokeStyle;
                canvasContext.stroke();
            }

            canvasContext.globalCompositeOperation = 'source-over'; //set to default value
        }
    };
})();

export default DrawUtility;
  • SafariUtility.js - содержит набор решений, направленных на обеспечение совместимости с браузером Safari, включая методы определения браузера, а также применения размытия.

SafariUtility.js
const SafariUtility = (function () {

    // Private Area
    var canvas = null;
    var ctx = null;
    var canvas_off = null;
    var ctx_off = null;

    return { // Public Area
        /**
         * @returns {boolean}
         */
        isSafari: function () {
            return /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
        },

        /**
         * Set canvas before working
         * @param canvasObject
         */
        setCanvas(canvasObject){
            canvas = canvasObject;
            ctx = canvasObject.getContext('2d');
            let w = canvasObject.width;
            let h = canvasObject.height;
            canvas_off = document.createElement("canvas");
            ctx_off = canvas_off.getContext("2d");
            canvas_off.width = w;
            canvas_off.height = h;
            ctx_off.drawImage(canvasObject, 0, 0);
        },

        /**
         * Recover canvas
         */
        recoverCanvas(){
            let w = canvas_off.width;
            let h = canvas_off.height;
            canvas.width = w;
            canvas.height = h;
            ctx.drawImage(this.canvas_off,0,0);
        },

        /**
         * Gassuan blur
         * @param blur
         */
        gBlur(blur) {
            let sum = 0;
            let delta = 5;
            let alpha_left = 1 / (2 * Math.PI * delta * delta);
            let step = blur < 3 ? 1 : 2;
            for (let y = -blur; y <= blur; y += step) {
                for (let x = -blur; x <= blur; x += step) {
                    let weight = alpha_left * Math.exp(-(x * x + y * y) / (2 * delta * delta));
                    sum += weight;
                }
            }
            let count = 0;
            for (let y = -blur; y <= blur; y += step) {
                for (let x = -blur; x <= blur; x += step) {
                    count++;
                    ctx.globalAlpha = alpha_left * Math.exp(-(x * x + y * y) / (2 * delta * delta)) / sum * blur;
                    ctx.drawImage(canvas,x,y);
                }
            }
            ctx.globalAlpha = 1;
        },

        /**
         * @param distance
         */
        mBlur(distance){
            distance = distance<0?0:distance;
            let w = canvas.width;
            let h = canvas.height;
            canvas.width = w;
            canvas.height = h;
            ctx.clearRect(0,0,w,h);

            for(let n=0;n<5;n+=0.1){
                ctx.globalAlpha = 1/(2*n+1);
                let scale = distance/5*n;
                ctx.transform(1+scale,0,0,1+scale,0,0);
                ctx.drawImage(canvas_off, 0, 0);
            }
            ctx.globalAlpha = 1;
            if(distance<0.01){
                window.requestAnimationFrame(()=>{
                    this.mBlur(distance+0.0005);
                });
            }
        }
    };
})();

export default SafariUtility;
  • TextureUtility.js - обрабатывает текстуры, используемые в макияже.

TextureUtility.js
const TextureUtility = (function () {
    return { // Public Area
        /**
         * Draw shimmer effect by coordinates
         * where coordinates represent the array of the following objects:
         *
         * {
         *  x: x,
         *  y: y,
         *  offsetX: 1,
         *  offsetY: 2,
         *  speedX: Math.random() * 2 - 1, // Random horizontal speed
         *  speedY: Math.random() * 2 - 1, // Random vertical speed
         * }
         *
         * @param canvas
         * @param shimmerCoordinates
         * @param shimmerSize
         */
        applyShimmer: function (canvas, shimmerCoordinates, shimmerSize) {
            shimmerCoordinates.forEach(shimmer => {
                let canvasContext = canvas.getContext('2d');
                canvasContext.fillStyle = '#ffffff';

                canvasContext.beginPath();
                canvasContext.arc(shimmer.x, shimmer.y, shimmerSize, 0, Math.PI * 2);
                canvasContext.fill();

                // Update glitter position
                shimmer.x += shimmer.speedX;
                shimmer.y += shimmer.speedY;

                // Wrap around canvas edges
                if (shimmer.x < 0 || shimmer.x > canvas.width) {
                    shimmer.x = shimmer.offsetX;
                }
                if (shimmer.y < 0 || shimmer.y > canvas.height) {
                    shimmer.y = shimmer.offsetY;
                }
            });
        }
    };
})();

export default TextureUtility;

Структура проекта: Effect

Здесь хранятся применяемые эффекты. Для удобства, я разделила эффекты на две группы - аксессуары и макияж.

Работа с аксессуарами на данный момент идет через создания 2D маски и наложения ее на лицо. Как создать маски на примере с очками смотрите ниже.

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

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

Пример базового объекта с настройками для эффекта матовой помады
const defaults = {
    transparency: 0.6,         // глобальная прозрачность эффекта
    blur: 2,                   // уровень размытия для сглаживания
    safariBlur: 1.5            // отдельное значение блюра для Safari
};

Для стандартизации и унификации параметров, которые передаются в визуальные эффекты, был введён объект effectSettings. Он представляет собой DTO (Data Transfer Object) — структуру данных, служащую для передачи настроек из внешнего слоя (например, UI или конфигурации пользователя) в конкретную реализацию эффекта.

Перед применением параметров, каждый эффект вызывает утилиту isValidEffectSettings() — она проверяет наличие и формат обязательных полей, таких как value. Это защищает от случайных ошибок при неправильной передаче данных.

Поскольку effectSettings — обычный JavaScript-объект, его можно адаптировать под любые нужды:

{
    value: '#D93F87',
    saturationBoost: 0.3,         // для эффекта увеличения насыщенности
    useMatteStyle: true,          // нестандартное поведение отрисовки
    safariFallbackEnabled: false  // отключение специфики для Safari
}

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

Пример использования effectSettings объекта в эффект классе
Пример использования effectSettings объекта в эффект классе

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

Пример того, как это выглядит, если просто "закрасить" область:

Чтобы добиться натурального эффекта, я внедрила алгоритм, основанный на смешивании реального цвета области с желаемым оттенком:

  1. Сначала я получаю средний цвет пикселей в области, которую нужно закрасить с помощью функции getAveragePixelsRgbColor(). Это позволяет понять, какие цвета уже присутствуют на изображении.

  2. Затем я смешиваю его с желаемым цветом, используя метод getAverageColor(). Такой подход создаёт эффект "тонального наложения", а не замещения.

  3. Иногда я также использую interpolateColors(), чтобы контролировать степень влияния нового цвета на оригинальный — от лёгкого оттенка до насыщенного окрашивания.

  4. Для придания бархатистости — например, в тенях или матовой помаде — я применяю toMatteColor().

Вот как уже выглядит матовая помада после интерполяции цветов:

ВАЖНО! Обратите внимание на TODO для эффектов. Убедитесь, что вы верно настроили пути!

BrowsColorEffect - пример наложения эффекта на брови
import ColorUtility from "../../Utility/ColorUtility.js";
import SafariUtility from "../../Utility/SafariUtility.js";
import DrawUtility from "../../Utility/DrawUtility.js";

/**
 * Apply brows color effect
 * @type {{apply: BrowsColorEffect.apply}}
 */
const BrowsColorEffect = (function () {

    // Private Area
    const defaults = {
        transparency: 0.33,
        blur: 3,
        safariBlur: 1, //hardcoded value don't change,
    };

    let maskCanvasElement = null; // will be used to make effect "behind the scene"
    let maskCanvasContext = null; // keep 2D rendering context for the canvas

    /**
     * Validate effect object
     * @param obj
     * @returns {boolean}
     */
    function isValidEffectSettings(obj) {
        return ColorUtility.isHexColor(obj.value);
    }

    /**
     * Need to create an additional canvas which will be used to make effect "behind the scene"
     */
    function initMaskCanvas() {
        if (maskCanvasElement == undefined || maskCanvasElement == null) {
            maskCanvasElement = document.createElement('canvas');
            maskCanvasContext = maskCanvasElement.getContext('2d');
        }
    }

    return { // Public Area
        /**
         * effect settings represents the following object:
         * {
         *  "type": "color",
         *  "value": "#0000",
         * }
         *
         * browsData represents the following object:
         * {
         *     "rightBrowContourCoordinates" : [{x: 000.22, y: 555}, .....],
         *     "leftBrowContourCoordinates": [{x: 000.22, y: 555}, .....];
         * }
         *
         * @param resultCanvasElement
         * @param browsData
         * @param effectSettings
         */
        apply: function (resultCanvasElement, browsData, effectSettings) {

            if (!isValidEffectSettings(effectSettings)) {
                throw new Error('Invalid brows effect settings object.');
            }

            initMaskCanvas();

            let resultCanvasContext = resultCanvasElement.getContext('2d');
            let width = resultCanvasElement.width;
            let height = resultCanvasElement.height;

            // resize masked canvas aligned with source canvas
            maskCanvasElement.width = width;
            maskCanvasElement.height = height;

            // uncomment if need to have supernatural effect and comment 2 lines below
            // let averageBrowsColor = ColorUtility.getAveragePixelsRgbColor(
            //     browsData.leftBrowContourCoordinates.concat(browsData.rightBrowContourCoordinates),
            //     resultCanvasContext
            // );
            // let appliedColor = ColorUtility.getAverageColor(averageBrowsColor, rgbAppliedColor);

            let rgbAppliedColor = ColorUtility.getRgbFromHex(effectSettings.value);
            let appliedColor = rgbAppliedColor; // to have more bright effect

            maskCanvasContext.clearRect(0, 0, width, height);

            // Draw and fill brows contour
            DrawUtility.drawContour(
                maskCanvasContext,
                browsData.rightBrowContourCoordinates,
                {fillStyle: `rgb(${appliedColor.r}, ${appliedColor.g}, ${appliedColor.b})`}
            );
            DrawUtility.drawContour(
                maskCanvasContext,
                browsData.leftBrowContourCoordinates,
                {fillStyle: `rgb(${appliedColor.r}, ${appliedColor.g}, ${appliedColor.b})`}
            );

            resultCanvasContext.globalAlpha = defaults.transparency;

            if (SafariUtility.isSafari()) {
                SafariUtility.setCanvas(maskCanvasElement);
                SafariUtility.gBlur(defaults.safariBlur);
            } else {
                resultCanvasContext.filter = `blur(${defaults.blur}px)`;
            }

            resultCanvasContext.drawImage(maskCanvasElement, 0, 0, width, height);

            // Reset filters and restore global transparency
            resultCanvasContext.filter = 'none';
            resultCanvasContext.globalAlpha = 1.0;
        }
    };
})();

export default BrowsColorEffect;

В каждом эффекте я оставила комментарии о том, какой цвет/размытие за что отвечает.

Также прошу заметить, что некоторые эффекты, например, ресницы, требуют дополнительных масок / подложек. Таким образом, я сделала маски для эффекта туши, а также для консилера, это находятся в Effect/Makeup/assets.

Как создать маску для наложения очков

В моем модуле я не использую 3D модели. Работа с очками заключается в создании маски 2D, а затем ее позиционирования относительно лица.

Чтобы применить маску очков, вам нужно создать её в формате PNG с прозрачным фоном, используя специальную базовую маску-наложение.

В моей библиотеке вы можете найти пример такого PSD-файла здесь:

  • _documentation/fixtures/overlay.psd

Откройте файл (требуется Photoshop или GIMP).

Вы увидите несколько слоев с изображениями, а также один черный слой с названием "Background-with-nose-center" и другой с названием "overlay":

Если вы хотите создать новую маску, то прежде всего, скройте все слои, кроме "overlay".

Скопируйте и вставьте новое PNG-изображение очков с прозрачным фоном. Измените размер изображения в соответствии с базовым слоем маски.

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

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

Затем экспортируйте это изображение в формате PNG.

Теперь изображение готово к использованию в качестве маски для очков!

Как подключить библиотеку

Подключите необходимый процессор (FaceMesh/VisionTask). Процессор будет обёрнут в публичный интерфейс VirtualMirror. Объект VirtualMirror экспортируется глобально через window, чтобы быть доступным в любом месте фронтенда без необходимости дополнительных импортов.

import FaceMeshProcessor from './Lib/Mediapipe/face_mesh/FaceMeshProcessor.js';

const VirtualMirror = (function () {

        return { // Public Area
            apply: function (sourceElementId, resultCanvasElementId, effectObject) {
                let element = document.getElementById(sourceElementId);
                let resultCanvasHTMLObject = document.getElementById(resultCanvasElementId);

                FaceMeshProcessor.process(element, resultCanvasHTMLObject, effectObject);
            },

            terminate: function () {
                FaceMeshProcessor.terminate();
            }

        };
    }
)();

Object.defineProperty(window, 'VirtualMirror', {
    value: VirtualMirror,
    writable: false,
    configurable: false
});

export default VirtualMirror;

Далее необходимо реализовать передачу выбранных effectSettings через UI, настроить html для корректной взаимосвязи интерфейса и библиотеки. Ниже показываю, как это сделано у меня.

Пример кода
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Virtual Mirror Library | Virtual Makeup Try-On</title>
    <script type="module" src="main.js"></script>
    <link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap" rel="stylesheet">
    <style>
        body {
            font-family: 'Roboto', sans-serif;
            display: flex;
            flex-direction: column;
            align-items: center;
            background: #f8f9fa;
            margin: 0;
            padding: 0;
        }
        header {
            width: 100%;
            padding: 20px;
            background-color: #343a40;
            color: #ffffff;
            text-align: center;
            box-shadow: 0 4px 6px rgba(0,0,0,0.1);
        }
        header h1 {
            margin: 0;
            font-size: 2em;
            font-weight: 500;
        }
        header p {
            margin: 5px 0 0;
            font-size: 1.2em;
            font-weight: 300;
        }
        .container {
            display: flex;
            flex-wrap: wrap;
            justify-content: center;
            width: 100%;
            max-width: 1200px;
            margin-top: 20px;
        }
        .column {
            box-shadow: 0 4px 6px rgba(0,0,0,0.1);
            background: #ffffff;
            border-radius: 8px;
            padding: 20px;
            margin: 10px;
            flex: 1;
            min-width: 250px;
        }
        #effectsColumn, #settingsColumn {
            max-width: 300px;
        }
        #modeColumn {
            flex: 2;
            text-align: center;
        }
        h2 {
            font-size: 1.5em;
            margin-bottom: 10px;
            color: #495057;
        }
        label {
            font-size: 1.1em;
            color: #212529;
        }
        input[type="radio"], select {
            margin-right: 10px;
        }
        #app {
            margin-top: 20px;
            position: relative;
            display: inline-block;
        }
        img {
            width: 100%;
            max-width: 700px;
            height: auto;
            border: 2px solid #dee2e6;
            border-radius: 8px;
        }
        video {
            display: none;
            width: 100%;
            max-width: 700px;
            height: auto;
            border: 2px solid #dee2e6;
            border-radius: 8px;
        }
        #mirrorCanvas {
            position: absolute;
            top: 0;
            left: 0;
            z-index: 1;
        }
    </style>
</head>
<body>

<header>
    <h1>Virtual Mirror: Discover Visual E-commerce</h1>
    <p>Bring an interactive shopping experience to your customers and set your brand apart.</p>
</header>

<div class="container">
    <div class="column" id="effectsColumn">
        <h2>Lips:</h2>
        <input type="radio" id="lipGloss" name="effect" value="LipGloss" valueType="color">
        <label for="lipGloss">Lip Gloss</label><br>

        <input type="radio" id="lipLiner" name="effect" value="LipLiner" valueType="color">
        <label for="lipLiner">Lip Liner</label><br>

        <input type="radio" id="lipstick" name="effect" value="Lipstick" valueType="color">
        <label for="lipstick">Lipstick</label><br>

        <input type="radio" id="lipstickShimmer" name="effect" value="LipstickShimmer" valueType="color">
        <label for="lipstickShimmer">Lipstick Shimmer</label><br>

        <input type="radio" id="matteLipstick" name="effect" value="MatteLipstick" valueType="color">
        <label for="matteLipstick">Matte Lipstick</label><br>

        <h2>Eyes:</h2>
        <input type="radio" id="browsColor" name="effect" value="BrowsColor" valueType="color">
        <label for="browsColor">Brows Color</label><br>

        <input type="radio" id="eyeliner" name="effect" value="Eyeliner" valueType="color">
        <label for="eyeliner">Eyeliner</label><br>

        <input type="radio" id="mascara" name="effect" value="Mascara" valueType="color">
        <label for="mascara">Mascara</label><br>

        <input type="radio" id="kajal" name="effect" value="Kajal" valueType="color">
        <label for="kajal">Kajal</label><br>

        <input type="radio" id="eyeshadowsatin" name="effect" value="EyeShadowSatin+" valueType="color">
        <label for="eyeshadowsatin">EyeShadow Satin</label><br>

        <input type="radio" id="eyeshadowmatte" name="effect" value="EyeShadowMatte" valueType="color">
        <label for="eyeshadowmatte">EyeShadow Matte</label><br>

        <input type="radio" id="eyeshadowshimmer" name="effect" value="EyeShadowShimmer" valueType="color">
        <label for="eyeshadowshimmer">EyeShadow Shimmer</label><br>

        <h2>Face:</h2>
        <input type="radio" id="foundationSatin" name="effect" value="FoundationSatin" valueType="color">
        <label for="foundationSatin">Foundation Satin</label><br>

        <input type="radio" id="foundationMatte" name="effect" value="FoundationMatte" valueType="color">
        <label for="foundationMatte">Foundation Matte</label><br>

        <input type="radio" id="concealer" name="effect" value="Concealer" valueType="color">
        <label for="concealer">Concealer</label><br>

        <input type="radio" id="contour" name="effect" value="Contour" valueType="color">
        <label for="contour">Contour/Bronzer</label><br>

        <h2>Accessories:</h2>
        <input type="radio" id="eyeglasses" name="effect" value="Eyeglasses" valueType="image">
        <label for="eyeglasses">Eyeglasses</label><br>
    </div>

    <div class="column" id="settingsColumn">
        <div id="effectControls">
            <div id="colorControl" style="display: none;">
                <label for="colorPicker">Choose Color:</label><br>
                <input type="color" id="colorPicker" name="colorPicker">
            </div>

            <div id="rangeTransparency" style="display: none;">
                <label for="transparency">Transparency</label><br>
                <input type="range" id="transparency" name="transparency">
            </div>

            <div id="rangeSaturation" style="display: none;">
                <label for="saturation">Saturation</label><br>
                <input type="range" id="saturation" name="saturation">
            </div>
        </div>
    </div>

    <div class="column" id="modeColumn">
        <div>
            <label for="modeSelect">Select Mode:</label>
            <select id="modeSelect">
                <option value="image" selected>ModeImage</option>
                <option value="video">ModeVideo</option>
            </select>
        </div>

        <div id="app">
            <canvas id="mirrorCanvas" style="display:none"></canvas>
            <video id="mirrorVideo" height="auto" playsinline="" autoplay="" muted="" width="700px" style="background: black"></video>
            <img id="mirrorImg" src="face.png"/>
        </div>
    </div>
</div>

<script>

    const constraints = {
        video: true
    };

    let selectedEffectName = null;
    let stream = null;
    let video = null;

    /**
     * Set appropriate values from constants
     */
    function setRadioValues() {
        document.getElementById("browsColor").value = window.EFFECT_BROWS_COLOR;
        document.getElementById("lipstick").value = window.EFFECT_LIPSTICK;
        document.getElementById("matteLipstick").value = window.EFFECT_MATTE_LIPSTICK;
        document.getElementById("eyeliner").value = window.EFFECT_EYELINER;
        document.getElementById("eyeglasses").value = window.EFFECT_EYEGLASSES;
        document.getElementById("lipLiner").value = window.EFFECT_LIPLINER;
        document.getElementById("lipGloss").value = window.EFFECT_LIP_GLOSS;
        document.getElementById("lipstickShimmer").value = window.EFFECT_LIPSTICK_SHIMMER;
        document.getElementById("kajal").value = window.EFFECT_KAJAL;
        document.getElementById("mascara").value = window.EFFECT_MASCARA;
        document.getElementById("foundationSatin").value = window.EFFECT_FOUNDATION_SATIN;
        document.getElementById("foundationMatte").value = window.EFFECT_FOUNDATION_MATTE;
        document.getElementById("contour").value = window.EFFECT_CONTOUR;
        document.getElementById("eyeshadowsatin").value = window.EFFECT_EYESHADOW_SATIN;
        document.getElementById("eyeshadowmatte").value = window.EFFECT_EYESHADOW_MATTE;
        document.getElementById("eyeshadowshimmer").value = window.EFFECT_EYESHADOW_SHIMMER;
    }

    /**
     * Init camera
     * @returns {Promise<void>}
     */
    async function initNavigatorMedia() {
        if (stream) {
            return;
        }

        if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
            stream = await navigator.mediaDevices.getUserMedia(constraints);
            video = document.getElementById("mirrorVideo");
            video.srcObject = stream;
            window.stream = stream;
        } else {
            throw new Error("getUserMedia() method is not supported by this browser");
        }
    }

    /**
     * Stop Camera
     * @returns {Promise<void>}
     */
    async function stopVideo() {
        if (stream) {
            stream.getTracks().forEach(track => track.stop());
            stream = null;
        }
    }

    /**
     * Show additional params for effect settings
     * like range bars and so on
     * @param effectName
     * @param effectType
     */
    function showEffectControls(effectName, effectType) {
        const colorControlDiv = document.getElementById("colorControl");
        const transparencyControlDiv = document.getElementById("rangeTransparency");
        const saturationControlDiv = document.getElementById("rangeSaturation");

        const transparencyRange = document.getElementById("transparency");
        const saturationRange = document.getElementById("saturation");

        // Hide all controls initially
        colorControlDiv.style.display = "none";
        transparencyControlDiv.style.display = "none";
        saturationControlDiv.style.display = "none";

        if (effectType === 'color') {
            document.getElementById("colorControl").style.display = "block";
        } else {
            document.getElementById("colorControl").style.display = "none";
        }

        switch (effectName) {
            case window.EFFECT_LIP_GLOSS:
                transparencyControlDiv.style.display = "block";
                transparencyRange.min = 0.15;
                transparencyRange.max = 0.7;
                transparencyRange.step = 0.01;
                transparencyRange.value = 0.15; // Set default value
                break;

            case window.EFFECT_LIPSTICK:
                saturationControlDiv.style.display = "block";
                saturationRange.min = 0;
                saturationRange.max = 1;
                saturationRange.step = 0.1;
                saturationRange.value = 0; // Set default value

                transparencyControlDiv.style.display = "block";
                transparencyRange.min = 0.15;
                transparencyRange.max = 0.3;
                transparencyRange.step = 0.01;
                transparencyRange.value = 0.15; // Set default value
                break;

            case window.EFFECT_LIPSTICK_SHIMMER:
                transparencyControlDiv.style.display = "block";
                transparencyRange.min = 0.15;
                transparencyRange.max = 0.5;
                transparencyRange.step = 0.01;
                transparencyRange.value = 0.15; // Set default value
                break;
        }
    }

    /**
     * Apply effect
     * @returns {Promise<void>}
     */
    async function applyEffect() {

        if (window.VirtualMirror) {
            await window.VirtualMirror.terminate();
        }

        const effectRadio = document.querySelector('input[name="effect"]:checked');

        if (!effectRadio) {
            return;
        }

        const mode = document.getElementById("modeSelect").value;
        const effectName = effectRadio.value;
        const valueType = effectRadio.getAttribute('valueType');
        const colorPicker = document.getElementById("colorPicker");
        const mirrorCanvas = document.getElementById('mirrorCanvas');
        const sourceImage = document.getElementById('mirrorImg');
        const sourceVideo = document.getElementById('mirrorVideo');

        if (mode === 'video') {
            await initNavigatorMedia();
            mirrorCanvas.style.display = 'block';
            sourceVideo.style.display = 'block';
            sourceImage.style.display = 'none';
        } else {
            await stopVideo();
            mirrorCanvas.style.display = 'block';
            sourceImage.style.display = 'block';
            sourceVideo.style.display = 'none';
        }

        if (selectedEffectName !== effectName) {
            selectedEffectName = effectName;
            showEffectControls(effectName, valueType);
        }

        const effectObject = {
            "effect": effectName,
            "type": valueType
        };


        if (valueType === 'color') {
            effectObject.value = colorPicker.value;
        }

        if (valueType === "image") {
            effectObject.value = "https://your_domain.com/mirror/glasses.png"; //TODO
        }

        // Depending on the effect type, update value for range inputs
        if (effectName === window.EFFECT_LIP_GLOSS || effectName === window.EFFECT_LIPSTICK_SHIMMER || effectName === window.EFFECT_LIPSTICK) {
            effectObject.transparency = document.getElementById("transparency").value;
        }
        if (effectName === window.EFFECT_LIPLINER || effectName === window.EFFECT_LIPSTICK || effectName === window.EFFECT_HAIR_COLOR) {
            effectObject.saturation = document.getElementById("saturation").value;
        }

        window.VirtualMirror.apply(mode === 'video' ? "mirrorVideo" : "mirrorImg", "mirrorCanvas", effectObject);
    }


    document.addEventListener('DOMContentLoaded', function () {
        setRadioValues(); // init values for radio buttons

        document.querySelectorAll('input[type=radio], select').forEach(item => {
            item.addEventListener('change', applyEffect);
        });

        document.querySelectorAll('input[type=range]').forEach(item => {
            item.addEventListener('input', applyEffect);
        });

        // Listen for the color picker change
        document.getElementById("colorPicker").addEventListener('input', applyEffect);
    });
</script>
</body>
</html>

Заключение

После настройки, вы можете накладывать эффекты

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

Продублирую ссылку на GitHub

Комментарии (0)