Привет! Я Аня, и очень люблю писать интересные интерености под 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