Привет! Я Аня, и очень люблю писать интересные интерености под E-commerce.
Ранее я уже писала о том, как создала POC модуля визуального поиска, сегодня хочу поделиться своей наработкой виртуального зеркала.
Библиотеку написала еще год-полтора назад, на то время было мало информации на эту тему, но зато большое количество предложений о покупке готовых модулей. Мне, как разработчику, стало интересно, а как же это все работает, и начала погружаться детальнее в эту тему.
Для нетерпеливых - вот ссылка на Github
ВАЖНО! ПРОЧИТАТЬ ПЕРЕД ДАЛЬНЕЙШИМ ЧТЕНИЕМ СТАТЬИ!!!!
1) Мой код не претендует на идеальный.
2) Библиотека не является абсолютно готовым продуктом, может иметь ошибки в работе, не идеальный накладываемый эффект, а так же некоторые нюасы в Safari. Кому нужен идеально работающий продукт - можно купить за много денег :)
3) Я буду рада почитать ваши конструктивные идеи/рекомендации/предложения в комментариях.
4) Код может содержать пометки с TODO - это нормально.
Содержание статьи
Введение:
Эта библиотека позволяет пользователям примерять различную косметику и аксессуары, так же, как они делали бы это с физическим зеркалом.
Основные функции
Поток с камеры в реальном времени: Библиотека использует веб-камеру пользователя для захвата видеопотока лица в реальном времени, позволяя ему видеть себя в реальном времени, примеряя различную косметику и аксессуары.
Применение эффектов к статическому изображению: Библиотека поддерживает применение макияжа и аксессуаров к статическим изображениям.
Доступные эффекты
Блеск для губ, Карандаш для губ, Помада, Помада с шиммером, Матовая помада, Цвет бровей, Подводка для глаз, Тушь, Карандаш для глаз (Каял), Тени для век сатиновые, Тени для век матовые, Тени для век с шиммером, Тональный крем сатиновый, Тональный крем матовый, Консилер, Контур/Бронзер, Очки
Системные требования
Доступная камера (для режима камеры): Убедитесь, что камера доступна.
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 для распознавания лица и ключевых точек. С его помощью библиотека точно определяет положение глаз, губ, носа и других областей, чтобы наложить макияж или аксессуары.
Определение точек на лице выглядит вот так:

Каждая точка имеет свои координаты в плоскости. Для того, чтобы закрасить определенную область, я выбирала координаты, замыкала их в фигуру, а после - применяла эффекты с наложением цвета/аксессуаров. Тут можно посмотреть полную документацию про 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 }
В дальнейшем можно расширить архитектуру эффектов под разные сценарии, включая тонкую настройку на основе пользовательского интерфейса (ползунки, палитры и т.д.).

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

Чтобы добиться натурального эффекта, я внедрила алгоритм, основанный на смешивании реального цвета области с желаемым оттенком:
Сначала я получаю средний цвет пикселей в области, которую нужно закрасить с помощью функции
getAveragePixelsRgbColor(). Это позволяет понять, какие цвета уже присутствуют на изображении.Затем я смешиваю его с желаемым цветом, используя метод
getAverageColor(). Такой подход создаёт эффект "тонального наложения", а не замещения.Иногда я также использую
interpolateColors(), чтобы контролировать степень влияния нового цвета на оригинальный — от лёгкого оттенка до насыщенного окрашивания.Для придания бархатистости — например, в тенях или матовой помаде — я применя��
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
