Virtual Mirror Library — Библиотека виртуального макияжа и онлайн примерки аксессуаров

от автора

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

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

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

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

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

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

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

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

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

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

  1. Введение

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

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

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

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

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

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

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

Введение:

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

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

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

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

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

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

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

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

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

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

Браузер

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

Chrome

52+

Firefox

35+

Internet Explorer

Н/Д*

Opera

39+

Safari

11+

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

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

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

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

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

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

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

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

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

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

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

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

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

Пример для GoogleChrome:

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

Браузер Mozilla:

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

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

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

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

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

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

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

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

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

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

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

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

- Lib   - Mediapipe       - face_mesh       - vision_task

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

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

FaceMeshProcessor
import FaceMeshEngine from './engine/FaceMeshEngine.js';  /**  * Processor of FaceMesh engine  * @type {{processVideo: ((function(*, *, *): Promise<boolean>)|*), processImage: (function(*, *, *): Promise<boolean>)}}  */ const FaceMeshProcessor = (function () {          let isAnimating = false;         let intervalId = null;          /**          * Validate that given object is <img> element.          * (can be get by document.getElementById("ID_STRING"))          *          * @param imageHtmlObject          */         function validateImageHtmlObject(imageHtmlObject) {             if (imageHtmlObject.tagName.toLowerCase() !== 'img') {                 throw new Error("Can not process image. The given object doesn't represent img tag");             }         }          /**          * Validate that given object is <video> element.          * (can be get by document.getElementById("ID_STRING"))          *          * @param videoHtmlObject          */         function validateVideoHtmlObject(videoHtmlObject) {             if (videoHtmlObject.tagName.toLowerCase() !== 'video') {                 throw new Error("Can not process video. The given object doesn't represent video tag");             }         }          /**          * Validate that given object is <canvas> element.          * (can be get by document.getElementById("ID_STRING"))          *          * @param canvasHtmlObject          */         function validateCanvasHtmlObject(canvasHtmlObject) {             if (canvasHtmlObject.tagName.toLowerCase() !== 'canvas') {                 throw new Error("Can not process video. The given object doesn't represent canvas tag");             }         }          /**          * Process video with requested effect          *          * Where:          *  sourceVideoHtmlObject - <video> html object          *  resultCanvasHTMLObject - canvas where to show the resulting output          *  effectObject - object with effect settings (see FaceMesh documentation to get more info)          *          * @param sourceVideoHtmlObject          * @param resultCanvasHTMLObject          * @param effectObject          * @returns {Promise<boolean>}          */         async function processVideo (sourceVideoHtmlObject, resultCanvasHTMLObject, effectObject) {              validateCanvasHtmlObject(resultCanvasHTMLObject);              async function drawResults() {                 validateVideoHtmlObject(sourceVideoHtmlObject);                  let resultCanvasContext = resultCanvasHTMLObject.getContext('2d');                 let width = sourceVideoHtmlObject.clientWidth;                 let height = sourceVideoHtmlObject.clientHeight;                  // make result canvas the same size as source video                 resultCanvasHTMLObject.width = width;                 resultCanvasHTMLObject.height = height;                  resultCanvasContext.drawImage(sourceVideoHtmlObject, 0, 0, width, height);                  await FaceMeshEngine.process(                     sourceVideoHtmlObject,                     resultCanvasHTMLObject,                     effectObject                 );             }              intervalId = setInterval(async () => {                 if (!isAnimating) {                     isAnimating = true;                     await drawResults();                     isAnimating = false;                 }             }, 10);         }          /**          * Process image with a requested effect          *          * Where:          *  SourceImageHtmlObject - <img> html object          *  resultCanvasHTMLObject - canvas where to show the resulting output          *  effectObject - object with effect settings (see FaceMesh documentation to get more info)          *          * @param sourceImageHtmlObject          * @param resultCanvasHTMLObject          * @param effectObject          * @returns {Promise<boolean>}          */         async function processImage (sourceImageHtmlObject, resultCanvasHTMLObject, effectObject) {             validateImageHtmlObject(sourceImageHtmlObject);             validateCanvasHtmlObject(resultCanvasHTMLObject);              // make result canvas the same size as source image             resultCanvasHTMLObject.width = sourceImageHtmlObject.width;             resultCanvasHTMLObject.height = sourceImageHtmlObject.height;              await FaceMeshEngine.process(                 sourceImageHtmlObject,                 resultCanvasHTMLObject,                 effectObject             );         }          return { // Public Area              /**              * Where processedElementHtmlObject can be either <img> or <video> html object              * @param processedElementHtmlObject              * @param resultCanvasHTMLObject              * @param effectObject              * @returns {Promise<void>}              */             process: async function(processedElementHtmlObject, resultCanvasHTMLObject, effectObject) {                 await this.terminate();                  if (processedElementHtmlObject.tagName.toLowerCase() === 'video') {                     await processVideo(processedElementHtmlObject, resultCanvasHTMLObject, effectObject);                 } else if (processedElementHtmlObject.tagName.toLowerCase() === 'img') {                     await processImage(processedElementHtmlObject, resultCanvasHTMLObject, effectObject);                 } else {                     this.terminate();                     throw new Error("Invalid source type. Can process img or video only");                 }             },              /**              * Stop detection              */             terminate: function () {                 clearInterval(intervalId);                 isAnimating = false;             }         };     } )();  export default FaceMeshProcessor; 
VisionTaskProcessor
import VisionTaskEngine from "./engine/VisionTaskEngine.js"; import * as Constants from "../../../Constants/EffectConstants.js";  /**  * @type {{launch: VisionTaskProcessor.launch, terminate: VisionTaskProcessor.terminate}}  */ const VisionTaskProcessor = (function () {          let isAnimating = false;         let intervalId = null;          /**          * Validate that given object is <img> element.          * (can be get by document.getElementById("ID_STRING"))          *          * @param imageHtmlObject          */         function validateImageHtmlObject(imageHtmlObject) {             if (imageHtmlObject.tagName.toLowerCase() !== 'img') {                 throw new Error("Can not process image. The given object doesn't represent img tag");             }         }          /**          * Validate that given object is <video> element.          * (can be get by document.getElementById("ID_STRING"))          *          * @param videoHtmlObject          */         function validateVideoHtmlObject(videoHtmlObject) {             if (videoHtmlObject.tagName.toLowerCase() !== 'video') {                 throw new Error("Can not process video. The given object doesn't represent video tag");             }         }          /**          * Validate that given object is <canvas> element.          * (can be get by document.getElementById("ID_STRING"))          *          * @param canvasHtmlObject          */         function validateCanvasHtmlObject(canvasHtmlObject) {             if (canvasHtmlObject.tagName.toLowerCase() !== 'canvas') {                 throw new Error("Can not process video. The given object doesn't represent canvas tag");             }         }          /**          * Process video with requested effect          *          * Where:          *  sourceVideoElementHTMLObject - <video> html object          *  resultCanvasHTMLObject - canvas where to show the resulting output          *  effectObject - object with effect settings (see FaceMesh documentation to get more info)          *          * @param sourceVideoElementHTMLObject          * @param resultCanvasHTMLObject          * @param effectObject          * @returns {Promise<void>}          */         async function processVideo (sourceVideoElementHTMLObject, resultCanvasHTMLObject, effectObject) {              validateVideoHtmlObject(sourceVideoElementHTMLObject);             validateCanvasHtmlObject(resultCanvasHTMLObject);              async function drawResults() {                  let landmarksDetected = false;                  let width = sourceVideoElementHTMLObject.clientWidth;                 let height = sourceVideoElementHTMLObject.clientHeight;                  // make result canvas the same size as source image                 resultCanvasHTMLObject.width = width;                 resultCanvasHTMLObject.height = height;                  if (effectObject.effect === Constants.EFFECT_HAIR_COLOR) {                     landmarksDetected = await VisionTaskEngine.processHairVideo(                         sourceVideoElementHTMLObject,                         resultCanvasHTMLObject,                         effectObject                     );                 } else {                     await VisionTaskEngine.processFaceVideo(                         sourceVideoElementHTMLObject,                         resultCanvasHTMLObject,                         effectObject                     );                 }             }              intervalId = setInterval(async () => {                 if (!isAnimating) {                     isAnimating = true;                     await drawResults();                     isAnimating = false;                 }             }, 10);         }          /**          * Process image with a requested effect          *          * Where:          *  sourceImageHtmlObject - <img> html object          *  resultCanvasHTMLObject - canvas where to show the resulting output          *  effectObject - object with effect settings (see FaceMesh documentation to get more info)          *          * @param sourceImageHtmlObject          * @param resultCanvasHTMLObject          * @param effectObject          * @returns {Promise<boolean>}          */         async function processImage (sourceImageHtmlObject, resultCanvasHTMLObject, effectObject) {             validateImageHtmlObject(sourceImageHtmlObject);             validateCanvasHtmlObject(resultCanvasHTMLObject);              // make result canvas the same size as source image             resultCanvasHTMLObject.width = sourceImageHtmlObject.width;             resultCanvasHTMLObject.height = sourceImageHtmlObject.height;              if (effectObject.effect === Constants.EFFECT_HAIR_COLOR) {                 return await VisionTaskEngine.processHairImage(                     sourceImageHtmlObject,                     resultCanvasHTMLObject,                     effectObject                 );             } else {                 return await VisionTaskEngine.processFaceImage(                     sourceImageHtmlObject,                     resultCanvasHTMLObject,                     effectObject                 );             }         }          return { // Public Area              /**              * Where processedElementHtmlObject can be either <img> or <video> html object              * @param processedElementHtmlObject              * @param resultCanvasHTMLObject              * @param effectObject              * @returns {Promise<void>}              */             process: async function(processedElementHtmlObject, resultCanvasHTMLObject, effectObject) {                 this.terminate();                  if (processedElementHtmlObject.tagName.toLowerCase() === 'video') {                     await processVideo(processedElementHtmlObject, resultCanvasHTMLObject, effectObject);                 } else if (processedElementHtmlObject.tagName.toLowerCase() === 'img') {                     await processImage(processedElementHtmlObject, resultCanvasHTMLObject, effectObject);                 } else {                     this.terminate();                     throw new Error("Invalid source type. Can process img or video only");                 }             },              /**              * Stop detection              */             terminate: function () {                 clearInterval(intervalId);                 isAnimating = false;             }         };     } )();  export default VisionTaskProcessor; 

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

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

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

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

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

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

Пример BrowsDetector
import CoordinatesUtility from "../core/coordinates_utils.js";  /**  * Define brows and return object of detected coordinates  * @type {{apply: BrowsColorEffect.apply}}  */ const BrowsDetector = (function () {      /**      * Return eye brow contour point coordinates      *      * @param landmarks      * @param eyebrowPoints      * @param canvas      * @returns {*}      */     function getEyebrowContourCoordinates(landmarks, eyebrowPoints, canvas) {         let bottomContour = eyebrowPoints.slice(1, 4);         let topContour = eyebrowPoints.slice(4).reverse();         let contour = bottomContour.concat(             [[bottomContour.slice(-1)[0][1], topContour[0][1]]],             topContour,             [[topContour.slice(-1)[0][0], bottomContour[0][1]]],         );         return contour.map(point => {             return CoordinatesUtility.getPointCoordinates(                 landmarks, point[0], canvas.width, canvas.height             );         });     }      return { // Public Area         /**          * Detect contours coordinates of brows and return them          * @param resultCanvasElement          * @param landmarks          * @param leftEyebrowPoints          * @param rightEyebrowPoints          * @returns {{leftBrowContourCoordinates: *, rightBrowContourCoordinates: *}}          */         detect: function (resultCanvasElement, landmarks, leftEyebrowPoints, rightEyebrowPoints) {              let rightBrowContourCoordinates = getEyebrowContourCoordinates(                 landmarks, rightEyebrowPoints, resultCanvasElement             );              let leftBrowContourCoordinates = getEyebrowContourCoordinates(                 landmarks, leftEyebrowPoints, resultCanvasElement             );              return {                 "rightBrowContourCoordinates": rightBrowContourCoordinates,                 "leftBrowContourCoordinates": leftBrowContourCoordinates             }         }     }; })();  export default BrowsDetector; 
  • Engine — отвечает за распознавание лица на изображениях или видео с помощью библиотеки MediaPipe FaceMesh и нанесение выбранных визуальных эффектов, таких как макияж или аксессуары.

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

Пример FaceMeshEngine.js
// Detectors import BrowsDetector from '../detector/BrowsDetector.js'; import EyesDetector from '../detector/EyesDetector.js'; import LipsDetector from '../detector/LipsDetector.js'; import JawDetector from '../detector/JawDetector.js'; import FaceDetector from '../detector/FaceDetector.js';  // Makeup effects import LipstickEffect from '../../../../Effect/Makeup/Lipstick.js'; import LipstickMatteEffect from '../../../../Effect/Makeup/LipstickMatte.js'; import BrowsColorEffect from '../../../../Effect/Makeup/BrowsColor.js'; import EyelinerEffect from '../../../../Effect/Makeup/Eyeliner.js'; import LipLinerEffect from '../../../../Effect/Makeup/LipLiner.js'; import LipGlossEffect from '../../../../Effect/Makeup/LipGloss.js'; import LipstickShimmerEffect from "../../../../Effect/Makeup/LipstickShimmer.js"; import KajalEffect from "../../../../Effect/Makeup/Kajal.js"; import MascaraEffect from "../../../../Effect/Makeup/Mascara.js"; import FoundationSatinEffect from "../../../../Effect/Makeup/FoundationSatin.js"; import FoundationMatteEffect from "../../../../Effect/Makeup/FoundationMatte.js"; import ConcealerEffect from "../../../../Effect/Makeup/Concealer.js"; import ContourEffect from "../../../../Effect/Makeup/Contour.js"; import EyeShadowSatin from "../../../../Effect/Makeup/EyeShadowSatin.js"; import EyeShadowMatte from "../../../../Effect/Makeup/EyeShadowMatte.js"; import EyeShadowShimmer from "../../../../Effect/Makeup/EyeShadowShimmer.js";  // Accessories effects import EyeGlassesEffect from '../../../../Effect/Accessories/EyeGlassesEffect.js';  // Constants Area import * as Constants from '../../../../Constants/EffectConstants.js';  /**  * FaceMeshEngine  * @type {{processVideo: ((function(*, *, *): Promise<void>)|*)}}  */ const FaceMeshEngine = (function () {          var faceMesh = null;         var initializationPromise = null; // Store the promise for initialization         var currentMode = null;          /**          * Initialize facemesh library here          */         function initialize() {             if (initializationPromise) {                 return initializationPromise; // Return existing promise if initialization is already in progress             }              initializationPromise = new Promise((resolve, reject) => {                 Promise.all([                     import('../core/camera_utils.js'),                     import('../core/control_utils.js'),                     import('../core/drawing_utils.js'),                     import('../core/face-mesh.js'),                 ])                     .then(async ([                         camera_utils,                         control_utils,                         drawing_utils,                         face_mesh,                     ]) => {                          faceMesh = await new FaceMesh({                             locateFile: (file) => {                                 return `/mirror/Lib/Mediapipe/face_mesh/core/wasm/${file}`; //TODO correct the path of images                             }                         });                          faceMesh.setOptions({                             maxNumFaces: 1,                             refineLandmarks: true,                             minDetectionConfidence: 0.5,                             minTrackingConfidence: 0.5                         });                          resolve(); // Resolve the promise once initialization is complete                     })                     .catch(error => {                         reject(error); // Reject the promise if initialization fails                     });             });              return initializationPromise;         }          /**          * Apply effect by a given result landmarks and effect object          * @param landmarks          * @param effectObject          * @param resultCanvasHTMLObject          */         function applyEffect(landmarks, effectObject, resultCanvasHTMLObject) {             let detectionData = null;              switch (effectObject.effect) {                  case Constants.EFFECT_BROWS_COLOR:                     detectionData = BrowsDetector.detect(                         resultCanvasHTMLObject,                         landmarks,                         FACEMESH_LEFT_EYEBROW,                         FACEMESH_RIGHT_EYEBROW                     );                     BrowsColorEffect.apply(resultCanvasHTMLObject, detectionData, effectObject);                     break;                  case Constants.EFFECT_LIPSTICK:                     detectionData = LipsDetector.detect(resultCanvasHTMLObject, landmarks, FACEMESH_LIPS);                     LipstickEffect.apply(resultCanvasHTMLObject, detectionData, effectObject);                     break;                  case Constants.EFFECT_LIPLINER:                     detectionData = LipsDetector.detect(resultCanvasHTMLObject, landmarks, FACEMESH_LIPS);                     LipLinerEffect.apply(resultCanvasHTMLObject, detectionData, effectObject);                     break;                  case Constants.EFFECT_LIP_GLOSS:                     detectionData = LipsDetector.detect(resultCanvasHTMLObject, landmarks, FACEMESH_LIPS);                     LipGlossEffect.apply(resultCanvasHTMLObject, detectionData, effectObject);                     break;                  case Constants.EFFECT_LIPSTICK_SHIMMER:                     detectionData = LipsDetector.detect(resultCanvasHTMLObject, landmarks, FACEMESH_LIPS);                     LipstickShimmerEffect.apply(resultCanvasHTMLObject, detectionData, effectObject);                     break;                  case Constants.EFFECT_MATTE_LIPSTICK:                     detectionData = LipsDetector.detect(resultCanvasHTMLObject, landmarks, FACEMESH_LIPS);                     LipstickMatteEffect.apply(resultCanvasHTMLObject, detectionData, effectObject);                     break;                  case Constants.EFFECT_EYELINER:                     detectionData = EyesDetector.detect(                         resultCanvasHTMLObject,                         landmarks,                         FACEMESH_TESSELATION,                         FACEMESH_LEFT_EYE,                         FACEMESH_RIGHT_EYE                     );                     EyelinerEffect.apply(resultCanvasHTMLObject, detectionData, effectObject);                     break;                  case Constants.EFFECT_EYESHADOW_SATIN:                     detectionData = EyesDetector.detect(                         resultCanvasHTMLObject,                         landmarks,                         FACEMESH_TESSELATION,                         FACEMESH_LEFT_EYE,                         FACEMESH_RIGHT_EYE                     );                     EyeShadowSatin.apply(resultCanvasHTMLObject, detectionData, effectObject);                     break;                  case Constants.EFFECT_EYESHADOW_MATTE:                     detectionData = EyesDetector.detect(                         resultCanvasHTMLObject,                         landmarks,                         FACEMESH_TESSELATION,                         FACEMESH_LEFT_EYE,                         FACEMESH_RIGHT_EYE                     );                     EyeShadowMatte.apply(resultCanvasHTMLObject, detectionData, effectObject);                     break;                  case Constants.EFFECT_EYESHADOW_SHIMMER:                     detectionData = EyesDetector.detect(                         resultCanvasHTMLObject,                         landmarks,                         FACEMESH_TESSELATION,                         FACEMESH_LEFT_EYE,                         FACEMESH_RIGHT_EYE                     );                     EyeShadowShimmer.apply(resultCanvasHTMLObject, detectionData, effectObject);                     break;                  case Constants.EFFECT_KAJAL:                     detectionData = EyesDetector.detect(                         resultCanvasHTMLObject,                         landmarks,                         FACEMESH_TESSELATION,                         FACEMESH_LEFT_EYE,                         FACEMESH_RIGHT_EYE                     );                     KajalEffect.apply(resultCanvasHTMLObject, detectionData, effectObject);                     break;                  case Constants.EFFECT_MASCARA:                     detectionData = EyesDetector.detect(                         resultCanvasHTMLObject,                         landmarks,                         FACEMESH_TESSELATION,                         FACEMESH_LEFT_EYE,                         FACEMESH_RIGHT_EYE                     );                     MascaraEffect.apply(resultCanvasHTMLObject, detectionData, effectObject);                     break;                  case Constants.EFFECT_EYEGLASSES:                     detectionData = EyesDetector.detect(                         resultCanvasHTMLObject,                         landmarks,                         FACEMESH_TESSELATION,                         FACEMESH_LEFT_EYE,                         FACEMESH_RIGHT_EYE                     );                     let jawData = JawDetector.detect(                         resultCanvasHTMLObject,                         landmarks,                         FACEMESH_FACE_OVAL                     );                     EyeGlassesEffect.apply(resultCanvasHTMLObject, detectionData, jawData, effectObject);                     break;                  case Constants.EFFECT_FOUNDATION_SATIN:                     detectionData = Object.assign(                         EyesDetector.detect(resultCanvasHTMLObject, landmarks, FACEMESH_TESSELATION, FACEMESH_LEFT_EYE, FACEMESH_RIGHT_EYE),                         FaceDetector.detect(resultCanvasHTMLObject, landmarks, FACEMESH_FACE_OVAL),                         BrowsDetector.detect(resultCanvasHTMLObject, landmarks, FACEMESH_LEFT_EYEBROW, FACEMESH_RIGHT_EYEBROW),                         LipsDetector.detect(resultCanvasHTMLObject, landmarks, FACEMESH_LIPS)                     );                      FoundationSatinEffect.apply(resultCanvasHTMLObject, detectionData, effectObject);                     break;                  case Constants.EFFECT_FOUNDATION_MATTE:                     detectionData = Object.assign(                         EyesDetector.detect(resultCanvasHTMLObject, landmarks, FACEMESH_TESSELATION, FACEMESH_LEFT_EYE, FACEMESH_RIGHT_EYE),                         FaceDetector.detect(resultCanvasHTMLObject, landmarks, FACEMESH_FACE_OVAL),                         BrowsDetector.detect(resultCanvasHTMLObject, landmarks, FACEMESH_LEFT_EYEBROW, FACEMESH_RIGHT_EYEBROW),                         LipsDetector.detect(resultCanvasHTMLObject, landmarks, FACEMESH_LIPS)                     );                      FoundationMatteEffect.apply(resultCanvasHTMLObject, detectionData, effectObject);                     break;                  case Constants.EFFECT_CONCEALER:                     detectionData = EyesDetector.detect(                         resultCanvasHTMLObject,                         landmarks,                         FACEMESH_TESSELATION,                         FACEMESH_LEFT_EYE,                         FACEMESH_RIGHT_EYE                     );                      ConcealerEffect.apply(resultCanvasHTMLObject, detectionData, effectObject);                     break;                  case Constants.EFFECT_CONTOUR:                     detectionData = FaceDetector.detect(resultCanvasHTMLObject, landmarks, FACEMESH_FACE_OVAL);                     ContourEffect.apply(resultCanvasHTMLObject, detectionData, effectObject);                     break;                  default:                     throw new Error('Unknown effect type: ' + effectObject.effect);                     break;             }         }          return { // Public Area              /**              * Process video or image with requested effect              *              * Where:              *  processedElementHTMLObject - it can be either <img> html object or <video> html object              *  resultCanvasHTMLObject - canvas where to show the resulting output              *  effectObject - object with effect settings (see FaceMesh documentation to get more info)              *              * @param processedElementHTMLObject              * @param resultCanvasHTMLObject              * @param effectObject              * @returns {Promise<boolean>}              */             process: async function (processedElementHTMLObject, resultCanvasHTMLObject, effectObject) {                 let landmarksDetected = false;                  if (faceMesh == undefined || faceMesh == null) {                     await initialize();                 }                  if (currentMode !== processedElementHTMLObject.tagName.toLowerCase()) {                     currentMode = processedElementHTMLObject.tagName.toLowerCase();                     await faceMesh.reset();                 }                  faceMesh.onResults(async function (results) {                     let faceLandmarksPoints = results.multiFaceLandmarks[0];                      if (faceLandmarksPoints) {                         let resultCanvasContext = resultCanvasHTMLObject.getContext('2d');                          // clean result canvas and display captured image from video                         resultCanvasContext.clearRect(                             0, 0, processedElementHTMLObject.clientWidth, processedElementHTMLObject.clientHeight                         );                         resultCanvasContext.drawImage(                             results.image, 0, 0, resultCanvasHTMLObject.width, resultCanvasHTMLObject.height                         );                          await applyEffect(faceLandmarksPoints, effectObject, resultCanvasHTMLObject);                          landmarksDetected = true;                     } else {                         landmarksDetected = false;                     }                 });                  await faceMesh.send({image: processedElementHTMLObject});                  return landmarksDetected;             },         };     } )();  export default FaceMeshEngine;

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

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

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

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

ColorUtility.js
const ColorUtility= (function () {     return { // Public Area          /**          * Check if the "color" is a valid hexadecimal color code          * @param colorValue          * @returns {boolean}          */         isHexColor: function (colorValue) {             const colorRegex = /^#[0-9A-Fa-f]{6}$/;             return colorRegex.test(colorValue);         },          /**          * Mix color in natural way          * where factor is domination of desired color from 0 to 1          * @param desiredColor          * @param originalColor          * @param factor          * @param transparency          * @returns {{a: number, r: *, b: *, g: *}}          */         interpolateColors: function (desiredColor, originalColor, factor, transparency) {             let p = factor / 100;             return {                 r: (desiredColor.r - originalColor.r) * p + originalColor.r,                 g: (desiredColor.g - originalColor.g) * p + originalColor.g,                 b: (desiredColor.b - originalColor.b) * p + originalColor.b,                 a: Math.round(transparency * 255)             };         },          /**          * Make color matte          * @param {{a, r: number, b: number, g: number}} color          * @returns {{a, r: number, b: number, g: number}}          */         toMatteColor: function (color) {             // Adjust the saturation and brightness to create a matte effect             const saturation = 0.9; // Adjust the value as needed             const brightness = 0.9; // Adjust the value as needed              const hslColor = this.rgbToHsl(color.r, color.g, color.b);              // Apply the saturation and brightness modifications             const modifiedHslColor = {                 h: hslColor.h,                 s: hslColor.s * saturation,                 l: hslColor.l * brightness,             };              // Convert the modified HSL color back to RGB color space             const modifiedRgbColor = this.hslToRgb(modifiedHslColor.h, modifiedHslColor.s, modifiedHslColor.l);              return {                 r: modifiedRgbColor.r,                 g: modifiedRgbColor.g,                 b: modifiedRgbColor.b,                 a: color.a,             };         },          /**          * Warning! areaColor and desiredColor must represent an object: {r: number, b: number, g: number}          * Return average color between applied area (e.g. lips) and desired color          * Need it to get more natural effect          *          * @param {{a, r: number, b: number, g: number}} areaColor          * @param {{a, r: number, b: number, g: number}} desiredColor          * @returns {{r: number, b: number, g: number}}          */         getAverageColor: function (areaColor, desiredColor) {             return {                 r: Math.round((areaColor.r + desiredColor.r) / 2),                 g: Math.round((areaColor.g + desiredColor.g) / 2),                 b: Math.round((areaColor.b + desiredColor.b) / 2)             };         },          /**          * saturationIncrease can be between 0 and 1          * @param rgbColor          * @param saturation          * @returns {*}          */         increaseSaturation: function (rgbColor, saturation) {             if (saturation < 0 || saturation > 1) {                 throw new Error('Invalid saturation value.');             }              let hslColor = this.rgbToHsl(rgbColor.r, rgbColor.g, rgbColor.b);              // Increase the saturation             hslColor.s += saturation;              // Ensure saturation is within [0, 1] range             hslColor.s = Math.max(0, Math.min(1, hslColor.s));              // Convert HSL back to RGB             return this.hslToRgb(hslColor.h, hslColor.s, hslColor.l);         },          /**          * Convert hex color to RGB object          *          * @param hex          * @returns {{r: number, b: number, g: number}}          */         getRgbFromHex: function (hex) {             hex = hex.replace('#', '');              let decimal = parseInt(hex, 16); // Convert hexadecimal to decimal             let r = (decimal >> 16) & 255; // Extract red component from decimal value             let g = (decimal >> 8) & 255; // Extract green component from decimal value             let b = decimal & 255; // Extract blue component from decimal value              //don't allow 0 value, it won't be applied in mask             r = r + 1;             g = g + 1;             b = b + 1;              return {r, g, b};         },          /**          * @param r          * @param g          * @param b          * @returns {{s: number, h: number, l: number}}          */         rgbToHsl: function (r, g, b) {             r /= 255;             g /= 255;             b /= 255;              const max = Math.max(r, g, b);             const min = Math.min(r, g, b);             let h, s, l = (max + min) / 2;              if (max === min) {                 h = s = 0; // achromatic             } else {                 const d = max - min;                 s = l > 0.5 ? d / (2 - max - min) : d / (max + min);                 switch (max) {                     case r:                         h = (g - b) / d + (g < b ? 6 : 0);                         break;                     case g:                         h = (b - r) / d + 2;                         break;                     case b:                         h = (r - g) / d + 4;                         break;                 }                 h /= 6;             }              return {                 h: h,                 s: s,                 l: l             };         },          /**          * @param h          * @param s          * @param l          * @returns {{r: number, b: number, g: number}}          */         hslToRgb: function (h, s, l) {             let r, g, b;              if (s === 0) {                 r = g = b = l; // achromatic             } else {                 function hue2rgb(p, q, t) {                     if (t < 0) t += 1;                     if (t > 1) t -= 1;                     if (t < 1 / 6) return p + (q - p) * 6 * t;                     if (t < 1 / 2) return q;                     if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;                     return p;                 }                  const q = l < 0.5 ? l * (1 + s) : l + s - l * s;                 const p = 2 * l - q;                  r = hue2rgb(p, q, h + 1 / 3);                 g = hue2rgb(p, q, h);                 b = hue2rgb(p, q, h - 1 / 3);             }              return {                 r: Math.round(r * 255),                 g: Math.round(g * 255),                 b: Math.round(b * 255),             };         },          /**          * Return average color of provided pixels from source canvas image          * Need to know this color to avoid not natural applied effect          *          * @param coordinates          * @param canvasContext          * @returns {{r: number, b: number, g: number}}          */         getAveragePixelsRgbColor: function (coordinates, canvasContext) {             let averageColor = {r: 0, g: 0, b: 0};             let width = 1; // width of the area to capture (in this case, 1 pixel)             let height = 1; // height of the area to capture (in this case, 1 pixel)              for (var i = 0; i < coordinates.length; i++) {                  // Capture the image data of the current pixel area                 let imageData = canvasContext.getImageData(                     coordinates[i].x,                     coordinates[i].y,                     width,                     height                 );                  averageColor.r += imageData.data[0]; // Red component of the pixel color                 averageColor.g += imageData.data[1]; // Green component of the pixel color                 averageColor.b += imageData.data[2]; // Blue component of the pixel color             }              // Divide the total color components by the number of areas to get the average color             // and round the average color components to integers             let numAreas = coordinates.length;              averageColor.r = Math.round(averageColor.r / numAreas);             averageColor.g = Math.round(averageColor.g / numAreas);             averageColor.b = Math.round(averageColor.b / numAreas);              return averageColor;         }     }; })();  export default ColorUtility; 
  • CoordinatesUtility.js — содержит функции для работы с координатами, полученными от движков распознавания лица.

CoordinatesUtility.js
const CoordinatesUtility = (function () {     return { // Public Area         /**          * Return distance in pixels          * @param point1          * @param point2          *          * where point is:          * {          *  x: number,          *  y: number          * }          * @returns {number}          */         getDistanceBetweenPoints: function (point1, point2) {             return Math.sqrt(Math.pow(point1.x - point2.x, 2) + Math.pow(point1.y - point2.y, 2));         },     }; })();  export default CoordinatesUtility; 
  • DrawUtility.js — предоставляет функции отрисовки на канвасе

DrawUtility.js
const DrawUtility = (function () {     return { // Public Area         /**          * Draw contour by provided coordinates          *          * @param canvasContext          * @param coordinates          * @param options          */         drawContour: function (canvasContext, coordinates, options = {}) {             if (options.globalCompositeOperation) {                 canvasContext.globalCompositeOperation = options.globalCompositeOperation;             }              canvasContext.beginPath();              for (let i = 0; i < coordinates.length; i++) {                 const point = coordinates[i];                 (i === 0) ? canvasContext.moveTo(point.x, point.y) : canvasContext.lineTo(point.x, point.y);             }              canvasContext.closePath();              if (options.fillStyle) {                 canvasContext.fillStyle = options.fillStyle;                 canvasContext.fill();             }              if (options.lineWidth) {                 canvasContext.lineWidth = options.lineWidth;             }              if (options.strokeStyle) {                 canvasContext.strokeStyle = options.strokeStyle;                 canvasContext.stroke();             }              canvasContext.globalCompositeOperation = 'source-over'; //set to default value         }     }; })();  export default DrawUtility; 
  • SafariUtility.js — содержит набор решений, направленных на обеспечение совместимости с браузером Safari, включая методы определения браузера, а также применения размытия.

SafariUtility.js
const SafariUtility = (function () {      // Private Area     var canvas = null;     var ctx = null;     var canvas_off = null;     var ctx_off = null;      return { // Public Area         /**          * @returns {boolean}          */         isSafari: function () {             return /^((?!chrome|android).)*safari/i.test(navigator.userAgent);         },          /**          * Set canvas before working          * @param canvasObject          */         setCanvas(canvasObject){             canvas = canvasObject;             ctx = canvasObject.getContext('2d');             let w = canvasObject.width;             let h = canvasObject.height;             canvas_off = document.createElement("canvas");             ctx_off = canvas_off.getContext("2d");             canvas_off.width = w;             canvas_off.height = h;             ctx_off.drawImage(canvasObject, 0, 0);         },          /**          * Recover canvas          */         recoverCanvas(){             let w = canvas_off.width;             let h = canvas_off.height;             canvas.width = w;             canvas.height = h;             ctx.drawImage(this.canvas_off,0,0);         },          /**          * Gassuan blur          * @param blur          */         gBlur(blur) {             let sum = 0;             let delta = 5;             let alpha_left = 1 / (2 * Math.PI * delta * delta);             let step = blur < 3 ? 1 : 2;             for (let y = -blur; y <= blur; y += step) {                 for (let x = -blur; x <= blur; x += step) {                     let weight = alpha_left * Math.exp(-(x * x + y * y) / (2 * delta * delta));                     sum += weight;                 }             }             let count = 0;             for (let y = -blur; y <= blur; y += step) {                 for (let x = -blur; x <= blur; x += step) {                     count++;                     ctx.globalAlpha = alpha_left * Math.exp(-(x * x + y * y) / (2 * delta * delta)) / sum * blur;                     ctx.drawImage(canvas,x,y);                 }             }             ctx.globalAlpha = 1;         },          /**          * @param distance          */         mBlur(distance){             distance = distance<0?0:distance;             let w = canvas.width;             let h = canvas.height;             canvas.width = w;             canvas.height = h;             ctx.clearRect(0,0,w,h);              for(let n=0;n<5;n+=0.1){                 ctx.globalAlpha = 1/(2*n+1);                 let scale = distance/5*n;                 ctx.transform(1+scale,0,0,1+scale,0,0);                 ctx.drawImage(canvas_off, 0, 0);             }             ctx.globalAlpha = 1;             if(distance<0.01){                 window.requestAnimationFrame(()=>{                     this.mBlur(distance+0.0005);                 });             }         }     }; })();  export default SafariUtility; 
  • TextureUtility.js — обрабатывает текстуры, используемые в макияже.

TextureUtility.js
const TextureUtility = (function () {     return { // Public Area         /**          * Draw shimmer effect by coordinates          * where coordinates represent the array of the following objects:          *          * {          *  x: x,          *  y: y,          *  offsetX: 1,          *  offsetY: 2,          *  speedX: Math.random() * 2 - 1, // Random horizontal speed          *  speedY: Math.random() * 2 - 1, // Random vertical speed          * }          *          * @param canvas          * @param shimmerCoordinates          * @param shimmerSize          */         applyShimmer: function (canvas, shimmerCoordinates, shimmerSize) {             shimmerCoordinates.forEach(shimmer => {                 let canvasContext = canvas.getContext('2d');                 canvasContext.fillStyle = '#ffffff';                  canvasContext.beginPath();                 canvasContext.arc(shimmer.x, shimmer.y, shimmerSize, 0, Math.PI * 2);                 canvasContext.fill();                  // Update glitter position                 shimmer.x += shimmer.speedX;                 shimmer.y += shimmer.speedY;                  // Wrap around canvas edges                 if (shimmer.x < 0 || shimmer.x > canvas.width) {                     shimmer.x = shimmer.offsetX;                 }                 if (shimmer.y < 0 || shimmer.y > canvas.height) {                     shimmer.y = shimmer.offsetY;                 }             });         }     }; })();  export default TextureUtility; 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

BrowsColorEffect — пример наложения эффекта на брови
import ColorUtility from "../../Utility/ColorUtility.js"; import SafariUtility from "../../Utility/SafariUtility.js"; import DrawUtility from "../../Utility/DrawUtility.js";  /**  * Apply brows color effect  * @type {{apply: BrowsColorEffect.apply}}  */ const BrowsColorEffect = (function () {      // Private Area     const defaults = {         transparency: 0.33,         blur: 3,         safariBlur: 1, //hardcoded value don't change,     };      let maskCanvasElement = null; // will be used to make effect "behind the scene"     let maskCanvasContext = null; // keep 2D rendering context for the canvas      /**      * Validate effect object      * @param obj      * @returns {boolean}      */     function isValidEffectSettings(obj) {         return ColorUtility.isHexColor(obj.value);     }      /**      * Need to create an additional canvas which will be used to make effect "behind the scene"      */     function initMaskCanvas() {         if (maskCanvasElement == undefined || maskCanvasElement == null) {             maskCanvasElement = document.createElement('canvas');             maskCanvasContext = maskCanvasElement.getContext('2d');         }     }      return { // Public Area         /**          * effect settings represents the following object:          * {          *  "type": "color",          *  "value": "#0000",          * }          *          * browsData represents the following object:          * {          *     "rightBrowContourCoordinates" : [{x: 000.22, y: 555}, .....],          *     "leftBrowContourCoordinates": [{x: 000.22, y: 555}, .....];          * }          *          * @param resultCanvasElement          * @param browsData          * @param effectSettings          */         apply: function (resultCanvasElement, browsData, effectSettings) {              if (!isValidEffectSettings(effectSettings)) {                 throw new Error('Invalid brows effect settings object.');             }              initMaskCanvas();              let resultCanvasContext = resultCanvasElement.getContext('2d');             let width = resultCanvasElement.width;             let height = resultCanvasElement.height;              // resize masked canvas aligned with source canvas             maskCanvasElement.width = width;             maskCanvasElement.height = height;              // uncomment if need to have supernatural effect and comment 2 lines below             // let averageBrowsColor = ColorUtility.getAveragePixelsRgbColor(             //     browsData.leftBrowContourCoordinates.concat(browsData.rightBrowContourCoordinates),             //     resultCanvasContext             // );             // let appliedColor = ColorUtility.getAverageColor(averageBrowsColor, rgbAppliedColor);              let rgbAppliedColor = ColorUtility.getRgbFromHex(effectSettings.value);             let appliedColor = rgbAppliedColor; // to have more bright effect              maskCanvasContext.clearRect(0, 0, width, height);              // Draw and fill brows contour             DrawUtility.drawContour(                 maskCanvasContext,                 browsData.rightBrowContourCoordinates,                 {fillStyle: `rgb(${appliedColor.r}, ${appliedColor.g}, ${appliedColor.b})`}             );             DrawUtility.drawContour(                 maskCanvasContext,                 browsData.leftBrowContourCoordinates,                 {fillStyle: `rgb(${appliedColor.r}, ${appliedColor.g}, ${appliedColor.b})`}             );              resultCanvasContext.globalAlpha = defaults.transparency;              if (SafariUtility.isSafari()) {                 SafariUtility.setCanvas(maskCanvasElement);                 SafariUtility.gBlur(defaults.safariBlur);             } else {                 resultCanvasContext.filter = `blur(${defaults.blur}px)`;             }              resultCanvasContext.drawImage(maskCanvasElement, 0, 0, width, height);              // Reset filters and restore global transparency             resultCanvasContext.filter = 'none';             resultCanvasContext.globalAlpha = 1.0;         }     }; })();  export default BrowsColorEffect; 

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

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

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

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

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

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

  • _documentation/fixtures/overlay.psd

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

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

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

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

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

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

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

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

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

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

import FaceMeshProcessor from './Lib/Mediapipe/face_mesh/FaceMeshProcessor.js';  const VirtualMirror = (function () {          return { // Public Area             apply: function (sourceElementId, resultCanvasElementId, effectObject) {                 let element = document.getElementById(sourceElementId);                 let resultCanvasHTMLObject = document.getElementById(resultCanvasElementId);                  FaceMeshProcessor.process(element, resultCanvasHTMLObject, effectObject);             },              terminate: function () {                 FaceMeshProcessor.terminate();             }          };     } )();  Object.defineProperty(window, 'VirtualMirror', {     value: VirtualMirror,     writable: false,     configurable: false });  export default VirtualMirror; 

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

Пример кода
<!DOCTYPE html> <html lang="en"> <head>     <meta charset="UTF-8">     <meta name="viewport" content="width=device-width, initial-scale=1.0">     <title>Virtual Mirror Library | Virtual Makeup Try-On</title>     <script type="module" src="main.js"></script>     <link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap" rel="stylesheet">     <style>         body {             font-family: 'Roboto', sans-serif;             display: flex;             flex-direction: column;             align-items: center;             background: #f8f9fa;             margin: 0;             padding: 0;         }         header {             width: 100%;             padding: 20px;             background-color: #343a40;             color: #ffffff;             text-align: center;             box-shadow: 0 4px 6px rgba(0,0,0,0.1);         }         header h1 {             margin: 0;             font-size: 2em;             font-weight: 500;         }         header p {             margin: 5px 0 0;             font-size: 1.2em;             font-weight: 300;         }         .container {             display: flex;             flex-wrap: wrap;             justify-content: center;             width: 100%;             max-width: 1200px;             margin-top: 20px;         }         .column {             box-shadow: 0 4px 6px rgba(0,0,0,0.1);             background: #ffffff;             border-radius: 8px;             padding: 20px;             margin: 10px;             flex: 1;             min-width: 250px;         }         #effectsColumn, #settingsColumn {             max-width: 300px;         }         #modeColumn {             flex: 2;             text-align: center;         }         h2 {             font-size: 1.5em;             margin-bottom: 10px;             color: #495057;         }         label {             font-size: 1.1em;             color: #212529;         }         input[type="radio"], select {             margin-right: 10px;         }         #app {             margin-top: 20px;             position: relative;             display: inline-block;         }         img {             width: 100%;             max-width: 700px;             height: auto;             border: 2px solid #dee2e6;             border-radius: 8px;         }         video {             display: none;             width: 100%;             max-width: 700px;             height: auto;             border: 2px solid #dee2e6;             border-radius: 8px;         }         #mirrorCanvas {             position: absolute;             top: 0;             left: 0;             z-index: 1;         }     </style> </head> <body>  <header>     <h1>Virtual Mirror: Discover Visual E-commerce</h1>     <p>Bring an interactive shopping experience to your customers and set your brand apart.</p> </header>  <div class="container">     <div class="column" id="effectsColumn">         <h2>Lips:</h2>         <input type="radio" id="lipGloss" name="effect" value="LipGloss" valueType="color">         <label for="lipGloss">Lip Gloss</label><br>          <input type="radio" id="lipLiner" name="effect" value="LipLiner" valueType="color">         <label for="lipLiner">Lip Liner</label><br>          <input type="radio" id="lipstick" name="effect" value="Lipstick" valueType="color">         <label for="lipstick">Lipstick</label><br>          <input type="radio" id="lipstickShimmer" name="effect" value="LipstickShimmer" valueType="color">         <label for="lipstickShimmer">Lipstick Shimmer</label><br>          <input type="radio" id="matteLipstick" name="effect" value="MatteLipstick" valueType="color">         <label for="matteLipstick">Matte Lipstick</label><br>          <h2>Eyes:</h2>         <input type="radio" id="browsColor" name="effect" value="BrowsColor" valueType="color">         <label for="browsColor">Brows Color</label><br>          <input type="radio" id="eyeliner" name="effect" value="Eyeliner" valueType="color">         <label for="eyeliner">Eyeliner</label><br>          <input type="radio" id="mascara" name="effect" value="Mascara" valueType="color">         <label for="mascara">Mascara</label><br>          <input type="radio" id="kajal" name="effect" value="Kajal" valueType="color">         <label for="kajal">Kajal</label><br>          <input type="radio" id="eyeshadowsatin" name="effect" value="EyeShadowSatin+" valueType="color">         <label for="eyeshadowsatin">EyeShadow Satin</label><br>          <input type="radio" id="eyeshadowmatte" name="effect" value="EyeShadowMatte" valueType="color">         <label for="eyeshadowmatte">EyeShadow Matte</label><br>          <input type="radio" id="eyeshadowshimmer" name="effect" value="EyeShadowShimmer" valueType="color">         <label for="eyeshadowshimmer">EyeShadow Shimmer</label><br>          <h2>Face:</h2>         <input type="radio" id="foundationSatin" name="effect" value="FoundationSatin" valueType="color">         <label for="foundationSatin">Foundation Satin</label><br>          <input type="radio" id="foundationMatte" name="effect" value="FoundationMatte" valueType="color">         <label for="foundationMatte">Foundation Matte</label><br>          <input type="radio" id="concealer" name="effect" value="Concealer" valueType="color">         <label for="concealer">Concealer</label><br>          <input type="radio" id="contour" name="effect" value="Contour" valueType="color">         <label for="contour">Contour/Bronzer</label><br>          <h2>Accessories:</h2>         <input type="radio" id="eyeglasses" name="effect" value="Eyeglasses" valueType="image">         <label for="eyeglasses">Eyeglasses</label><br>     </div>      <div class="column" id="settingsColumn">         <div id="effectControls">             <div id="colorControl" style="display: none;">                 <label for="colorPicker">Choose Color:</label><br>                 <input type="color" id="colorPicker" name="colorPicker">             </div>              <div id="rangeTransparency" style="display: none;">                 <label for="transparency">Transparency</label><br>                 <input type="range" id="transparency" name="transparency">             </div>              <div id="rangeSaturation" style="display: none;">                 <label for="saturation">Saturation</label><br>                 <input type="range" id="saturation" name="saturation">             </div>         </div>     </div>      <div class="column" id="modeColumn">         <div>             <label for="modeSelect">Select Mode:</label>             <select id="modeSelect">                 <option value="image" selected>ModeImage</option>                 <option value="video">ModeVideo</option>             </select>         </div>          <div id="app">             <canvas id="mirrorCanvas" style="display:none"></canvas>             <video id="mirrorVideo" height="auto" playsinline="" autoplay="" muted="" width="700px" style="background: black"></video>             <img id="mirrorImg" src="face.png"/>         </div>     </div> </div>  <script>      const constraints = {         video: true     };      let selectedEffectName = null;     let stream = null;     let video = null;      /**      * Set appropriate values from constants      */     function setRadioValues() {         document.getElementById("browsColor").value = window.EFFECT_BROWS_COLOR;         document.getElementById("lipstick").value = window.EFFECT_LIPSTICK;         document.getElementById("matteLipstick").value = window.EFFECT_MATTE_LIPSTICK;         document.getElementById("eyeliner").value = window.EFFECT_EYELINER;         document.getElementById("eyeglasses").value = window.EFFECT_EYEGLASSES;         document.getElementById("lipLiner").value = window.EFFECT_LIPLINER;         document.getElementById("lipGloss").value = window.EFFECT_LIP_GLOSS;         document.getElementById("lipstickShimmer").value = window.EFFECT_LIPSTICK_SHIMMER;         document.getElementById("kajal").value = window.EFFECT_KAJAL;         document.getElementById("mascara").value = window.EFFECT_MASCARA;         document.getElementById("foundationSatin").value = window.EFFECT_FOUNDATION_SATIN;         document.getElementById("foundationMatte").value = window.EFFECT_FOUNDATION_MATTE;         document.getElementById("contour").value = window.EFFECT_CONTOUR;         document.getElementById("eyeshadowsatin").value = window.EFFECT_EYESHADOW_SATIN;         document.getElementById("eyeshadowmatte").value = window.EFFECT_EYESHADOW_MATTE;         document.getElementById("eyeshadowshimmer").value = window.EFFECT_EYESHADOW_SHIMMER;     }      /**      * Init camera      * @returns {Promise<void>}      */     async function initNavigatorMedia() {         if (stream) {             return;         }          if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {             stream = await navigator.mediaDevices.getUserMedia(constraints);             video = document.getElementById("mirrorVideo");             video.srcObject = stream;             window.stream = stream;         } else {             throw new Error("getUserMedia() method is not supported by this browser");         }     }      /**      * Stop Camera      * @returns {Promise<void>}      */     async function stopVideo() {         if (stream) {             stream.getTracks().forEach(track => track.stop());             stream = null;         }     }      /**      * Show additional params for effect settings      * like range bars and so on      * @param effectName      * @param effectType      */     function showEffectControls(effectName, effectType) {         const colorControlDiv = document.getElementById("colorControl");         const transparencyControlDiv = document.getElementById("rangeTransparency");         const saturationControlDiv = document.getElementById("rangeSaturation");          const transparencyRange = document.getElementById("transparency");         const saturationRange = document.getElementById("saturation");          // Hide all controls initially         colorControlDiv.style.display = "none";         transparencyControlDiv.style.display = "none";         saturationControlDiv.style.display = "none";          if (effectType === 'color') {             document.getElementById("colorControl").style.display = "block";         } else {             document.getElementById("colorControl").style.display = "none";         }          switch (effectName) {             case window.EFFECT_LIP_GLOSS:                 transparencyControlDiv.style.display = "block";                 transparencyRange.min = 0.15;                 transparencyRange.max = 0.7;                 transparencyRange.step = 0.01;                 transparencyRange.value = 0.15; // Set default value                 break;              case window.EFFECT_LIPSTICK:                 saturationControlDiv.style.display = "block";                 saturationRange.min = 0;                 saturationRange.max = 1;                 saturationRange.step = 0.1;                 saturationRange.value = 0; // Set default value                  transparencyControlDiv.style.display = "block";                 transparencyRange.min = 0.15;                 transparencyRange.max = 0.3;                 transparencyRange.step = 0.01;                 transparencyRange.value = 0.15; // Set default value                 break;              case window.EFFECT_LIPSTICK_SHIMMER:                 transparencyControlDiv.style.display = "block";                 transparencyRange.min = 0.15;                 transparencyRange.max = 0.5;                 transparencyRange.step = 0.01;                 transparencyRange.value = 0.15; // Set default value                 break;         }     }      /**      * Apply effect      * @returns {Promise<void>}      */     async function applyEffect() {          if (window.VirtualMirror) {             await window.VirtualMirror.terminate();         }          const effectRadio = document.querySelector('input[name="effect"]:checked');          if (!effectRadio) {             return;         }          const mode = document.getElementById("modeSelect").value;         const effectName = effectRadio.value;         const valueType = effectRadio.getAttribute('valueType');         const colorPicker = document.getElementById("colorPicker");         const mirrorCanvas = document.getElementById('mirrorCanvas');         const sourceImage = document.getElementById('mirrorImg');         const sourceVideo = document.getElementById('mirrorVideo');          if (mode === 'video') {             await initNavigatorMedia();             mirrorCanvas.style.display = 'block';             sourceVideo.style.display = 'block';             sourceImage.style.display = 'none';         } else {             await stopVideo();             mirrorCanvas.style.display = 'block';             sourceImage.style.display = 'block';             sourceVideo.style.display = 'none';         }          if (selectedEffectName !== effectName) {             selectedEffectName = effectName;             showEffectControls(effectName, valueType);         }          const effectObject = {             "effect": effectName,             "type": valueType         };           if (valueType === 'color') {             effectObject.value = colorPicker.value;         }          if (valueType === "image") {             effectObject.value = "https://your_domain.com/mirror/glasses.png"; //TODO         }          // Depending on the effect type, update value for range inputs         if (effectName === window.EFFECT_LIP_GLOSS || effectName === window.EFFECT_LIPSTICK_SHIMMER || effectName === window.EFFECT_LIPSTICK) {             effectObject.transparency = document.getElementById("transparency").value;         }         if (effectName === window.EFFECT_LIPLINER || effectName === window.EFFECT_LIPSTICK || effectName === window.EFFECT_HAIR_COLOR) {             effectObject.saturation = document.getElementById("saturation").value;         }          window.VirtualMirror.apply(mode === 'video' ? "mirrorVideo" : "mirrorImg", "mirrorCanvas", effectObject);     }       document.addEventListener('DOMContentLoaded', function () {         setRadioValues(); // init values for radio buttons          document.querySelectorAll('input[type=radio], select').forEach(item => {             item.addEventListener('change', applyEffect);         });          document.querySelectorAll('input[type=range]').forEach(item => {             item.addEventListener('input', applyEffect);         });          // Listen for the color picker change         document.getElementById("colorPicker").addEventListener('input', applyEffect);     }); </script> </body> </html>

Заключение

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

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

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


ссылка на оригинал статьи https://habr.com/ru/articles/916332/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *