Русский
Русский
English
Статистика
Реклама

Tensorflow-js

Перевод Обнаружение эмоций на лице в браузере с помощью глубокого обучения и TensorFlow.js. Часть 2

02.03.2021 20:22:33 | Автор: admin

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

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

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


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

Настройка по данным об эмоциях на лице FER2013

Мы используем код для отслеживания лиц из предыдущей статьи, чтобы создать две веб-страницы. Одна страница будет использоваться для обучения модели ИИ на точках отслеженных лиц в наборе данных FER, а другая будет загружать обученную модель и применять её к тестовому набору данных.

Давайте изменим окончательный код из проекта отслеживания лиц, чтобы обучить нейросетевую модель и применить её к данным о лицах. Набор данных FER2013 состоит более чем из 28 тысяч помеченных изображений лиц; он доступен на веб-сайте Kaggle. Мы загрузили эту версию, в которой набор данных уже преобразован в файлы изображений, и поместили её в папку web/fer2013. Затем мы обновили код сервера NodeJS в index.js, чтобы он возвращал список ссылок на изображения по адресу http://localhost:8080/data/. Поэтому вы можете получить полный объект JSON, если запустите сервер локально.

Чтобы упростить задачу, мы сохранили этот объект JSON в файле web/fer2013.js, чтобы вы могли использовать его напрямую, не запуская сервер локально. Вы можете включить его в другие файлы скриптов в верхней части страницы:

<script src="web/fer2013.js"></script>

Мы собираемся работать с изображениями, а не с видео с веб-камеры (не беспокойтесь, мы вернёмся к видео в следующей статье). Поэтому нам нужно заменить элемент<video> элементом <img>и переименовать его ID в image. Мы также можем удалить функцию setupWebcam, так как для этого проекта она не нужна.

<img id="image" style="    visibility: hidden;    width: auto;    height: auto;    "/>

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

async function setImage( url ) {    return new Promise( res => {        let image = document.getElementById( "image" );        image.src = url;        image.onload = () => {            res();        };    });}function shuffleArray( array ) {    for( let i = array.length - 1; i > 0; i-- ) {        const j = Math.floor( Math.random() * ( i + 1 ) );        [ array[ i ], array[ j ] ] = [ array[ j ], array[ i ] ];    }}const OUTPUT_SIZE = 500;

Нам понадобятся некоторые глобальные переменные: для списка категорий эмоций, списка агрегированных массивов данных FER и индекса массива:

const emotions = [ "angry", "disgust", "fear", "happy", "neutral", "sad", "surprise" ];let ferData = [];let setIndex = 0;

Внутри блока async мы можем подготовить и перетасовать данные FER и изменить размер элемента canvas до 500x500 пикселей:

const minSamples = Math.min( ...Object.keys( fer2013 ).map( em => fer2013[ em ].length ) );Object.keys( fer2013 ).forEach( em => {    shuffleArray( fer2013[ em ] );    for( let i = 0; i < minSamples; i++ ) {        ferData.push({            emotion: em,            file: fer2013[ em ][ i ]        });    }});shuffleArray( ferData );let canvas = document.getElementById( "output" );canvas.width = OUTPUT_SIZE;canvas.height = OUTPUT_SIZE;

Нам нужно в последний раз обновить шаблон кода перед обучением модели ИИ на одной странице и применением обученной модели на второй странице. Необходимо обновить функцию trackFace, чтобы она работала с элементом image, а не video. Также требуется масштабировать ограничивающий прямоугольник и выходные данные сетки для лица в соответствии с размером элемента canvas. Мы зададим приращение setIndex в конце функции для перехода к следующему изображению.

async function trackFace() {    // Set to the next training image    await setImage( ferData[ setIndex ].file );    const image = document.getElementById( "image" );    const faces = await model.estimateFaces( {        input: image,        returnTensors: false,        flipHorizontal: false,    });    output.drawImage(        image,        0, 0, image.width, image.height,        0, 0, OUTPUT_SIZE, OUTPUT_SIZE    );    const scale = OUTPUT_SIZE / image.width;    faces.forEach( face => {        // Draw the bounding box        const x1 = face.boundingBox.topLeft[ 0 ];        const y1 = face.boundingBox.topLeft[ 1 ];        const x2 = face.boundingBox.bottomRight[ 0 ];        const y2 = face.boundingBox.bottomRight[ 1 ];        const bWidth = x2 - x1;        const bHeight = y2 - y1;        drawLine( output, x1, y1, x2, y1, scale );        drawLine( output, x2, y1, x2, y2, scale );        drawLine( output, x1, y2, x2, y2, scale );        drawLine( output, x1, y1, x1, y2, scale );        // Draw the face mesh        const keypoints = face.scaledMesh;        for( let i = 0; i < FaceTriangles.length / 3; i++ ) {            let pointA = keypoints[ FaceTriangles[ i * 3 ] ];            let pointB = keypoints[ FaceTriangles[ i * 3 + 1 ] ];            let pointC = keypoints[ FaceTriangles[ i * 3 + 2 ] ];            drawTriangle( output, pointA[ 0 ], pointA[ 1 ], pointB[ 0 ], pointB[ 1 ], pointC[ 0 ], pointC[ 1 ], scale );        }    });    setText( `${setIndex + 1}. Face Tracking Confidence: ${face.faceInViewConfidence.toFixed( 3 )} - ${ferData[ setIndex ].emotion}` );    setIndex++;    requestAnimationFrame( trackFace );}

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

1. Глубокое изучение эмоций на лице

В этом первом файле веб-страницы мы собираемся задать обучающие данные, создать нейросетевую модель, а затем обучить её и сохранить веса в файл. В код включена предварительно обученная модель (см. папку web/model), поэтому при желании можно пропустить эту часть и перейти к части2.

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

let trainingData = [];function emotionToArray( emotion ) {    let array = [];    for( let i = 0; i < emotions.length; i++ ) {        array.push( emotion === emotions[ i ] ? 1 : 0 );    }    return array;}

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

// Add just the nose, cheeks, eyes, eyebrows & mouthconst features = [    "noseTip",    "leftCheek",    "rightCheek",    "leftEyeLower1", "leftEyeUpper1",    "rightEyeLower1", "rightEyeUpper1",    "leftEyebrowLower", //"leftEyebrowUpper",    "rightEyebrowLower", //"rightEyebrowUpper",    "lipsLowerInner", //"lipsLowerOuter",    "lipsUpperInner", //"lipsUpperOuter",];let points = [];features.forEach( feature => {    face.annotations[ feature ].forEach( x => {        points.push( ( x[ 0 ] - x1 ) / bWidth );        points.push( ( x[ 1 ] - y1 ) / bHeight );    });});// Only grab the faces that are confidentif( face.faceInViewConfidence > 0.9 ) {    trainingData.push({        input: points,        output: ferData[ setIndex ].emotion,    });}

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

async function trackFace() {    // Fast train on just 200 of the images    if( setIndex >= 200 ) {        setText( "Finished!" );        trainNet();        return;    }    ...}

Наконец, мы пришли к той части, которую так долго ждали: давайте создадим функцию trainNet и обучим нашу модель ИИ!

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

async function trainNet() {    let inputs = trainingData.map( x => x.input );    let outputs = trainingData.map( x => emotionToArray( x.output ) );    // Define our model with several hidden layers    const model = tf.sequential();    model.add(tf.layers.dense( { units: 100, activation: "relu", inputShape: [ inputs[ 0 ].length ] } ) );    model.add(tf.layers.dense( { units: 100, activation: "relu" } ) );    model.add(tf.layers.dense( { units: 100, activation: "relu" } ) );    model.add(tf.layers.dense( {        units: emotions.length,        kernelInitializer: 'varianceScaling',        useBias: false,        activation: "softmax"    } ) );    model.compile({        optimizer: "adam",        loss: "categoricalCrossentropy",        metrics: "acc"    });    const xs = tf.stack( inputs.map( x => tf.tensor1d( x ) ) );    const ys = tf.stack( outputs.map( x => tf.tensor1d( x ) ) );    await model.fit( xs, ys, {        epochs: 1000,        shuffle: true,        callbacks: {            onEpochEnd: ( epoch, logs ) => {                setText( `Training... Epoch #${epoch} (${logs.acc.toFixed( 3 )})` );                console.log( "Epoch #", epoch, logs );            }        }    } );    // Download the trained model    const saveResult = await model.save( "downloads://facemo" );}

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

1. Финишная прямая

Вот полный код обучения модели на наборе данных FER:
<html>    <head>        <title>Training - Recognizing Facial Expressions in the Browser with Deep Learning using TensorFlow.js</title>        <script src="http://personeltest.ru/aways/cdn.jsdelivr.net/npm/@tensorflow/tfjs@2.4.0/dist/tf.min.js"></script>        <script src="http://personeltest.ru/aways/cdn.jsdelivr.net/npm/@tensorflow-models/face-landmarks-detection@0.0.1/dist/face-landmarks-detection.js"></script>        <script src="web/triangles.js"></script>        <script src="web/fer2013.js"></script>    </head>    <body>        <canvas id="output"></canvas>        <img id="image" style="            visibility: hidden;            width: auto;            height: auto;            "/>        <h1 id="status">Loading...</h1>        <script>        function setText( text ) {            document.getElementById( "status" ).innerText = text;        }        async function setImage( url ) {            return new Promise( res => {                let image = document.getElementById( "image" );                image.src = url;                image.onload = () => {                    res();                };            });        }        function shuffleArray( array ) {            for( let i = array.length - 1; i > 0; i-- ) {                const j = Math.floor( Math.random() * ( i + 1 ) );                [ array[ i ], array[ j ] ] = [ array[ j ], array[ i ] ];            }        }        function drawLine( ctx, x1, y1, x2, y2, scale = 1 ) {            ctx.beginPath();            ctx.moveTo( x1 * scale, y1 * scale );            ctx.lineTo( x2 * scale, y2 * scale );            ctx.stroke();        }        function drawTriangle( ctx, x1, y1, x2, y2, x3, y3, scale = 1 ) {            ctx.beginPath();            ctx.moveTo( x1 * scale, y1 * scale );            ctx.lineTo( x2 * scale, y2 * scale );            ctx.lineTo( x3 * scale, y3 * scale );            ctx.lineTo( x1 * scale, y1 * scale );            ctx.stroke();        }        const OUTPUT_SIZE = 500;        const emotions = [ "angry", "disgust", "fear", "happy", "neutral", "sad", "surprise" ];        let ferData = [];        let setIndex = 0;        let trainingData = [];        let output = null;        let model = null;        function emotionToArray( emotion ) {            let array = [];            for( let i = 0; i < emotions.length; i++ ) {                array.push( emotion === emotions[ i ] ? 1 : 0 );            }            return array;        }        async function trainNet() {            let inputs = trainingData.map( x => x.input );            let outputs = trainingData.map( x => emotionToArray( x.output ) );            // Define our model with several hidden layers            const model = tf.sequential();            model.add(tf.layers.dense( { units: 100, activation: "relu", inputShape: [ inputs[ 0 ].length ] } ) );            model.add(tf.layers.dense( { units: 100, activation: "relu" } ) );            model.add(tf.layers.dense( { units: 100, activation: "relu" } ) );            model.add(tf.layers.dense( {                units: emotions.length,                kernelInitializer: 'varianceScaling',                useBias: false,                activation: "softmax"            } ) );            model.compile({                optimizer: "adam",                loss: "categoricalCrossentropy",                metrics: "acc"            });            const xs = tf.stack( inputs.map( x => tf.tensor1d( x ) ) );            const ys = tf.stack( outputs.map( x => tf.tensor1d( x ) ) );            await model.fit( xs, ys, {                epochs: 1000,                shuffle: true,                callbacks: {                    onEpochEnd: ( epoch, logs ) => {                        setText( `Training... Epoch #${epoch} (${logs.acc.toFixed( 3 )})` );                        console.log( "Epoch #", epoch, logs );                    }                }            } );            // Download the trained model            const saveResult = await model.save( "downloads://facemo" );        }        async function trackFace() {            // Fast train on just 200 of the images            if( setIndex >= 200 ) {//ferData.length ) {                setText( "Finished!" );                trainNet();                return;            }            // Set to the next training image            await setImage( ferData[ setIndex ].file );            const image = document.getElementById( "image" );            const faces = await model.estimateFaces( {                input: image,                returnTensors: false,                flipHorizontal: false,            });            output.drawImage(                image,                0, 0, image.width, image.height,                0, 0, OUTPUT_SIZE, OUTPUT_SIZE            );            const scale = OUTPUT_SIZE / image.width;            faces.forEach( face => {                // Draw the bounding box                const x1 = face.boundingBox.topLeft[ 0 ];                const y1 = face.boundingBox.topLeft[ 1 ];                const x2 = face.boundingBox.bottomRight[ 0 ];                const y2 = face.boundingBox.bottomRight[ 1 ];                const bWidth = x2 - x1;                const bHeight = y2 - y1;                drawLine( output, x1, y1, x2, y1, scale );                drawLine( output, x2, y1, x2, y2, scale );                drawLine( output, x1, y2, x2, y2, scale );                drawLine( output, x1, y1, x1, y2, scale );                // Draw the face mesh                const keypoints = face.scaledMesh;                for( let i = 0; i < FaceTriangles.length / 3; i++ ) {                    let pointA = keypoints[ FaceTriangles[ i * 3 ] ];                    let pointB = keypoints[ FaceTriangles[ i * 3 + 1 ] ];                    let pointC = keypoints[ FaceTriangles[ i * 3 + 2 ] ];                    drawTriangle( output, pointA[ 0 ], pointA[ 1 ], pointB[ 0 ], pointB[ 1 ], pointC[ 0 ], pointC[ 1 ], scale );                }                // Add just the nose, cheeks, eyes, eyebrows & mouth                const features = [                    "noseTip",                    "leftCheek",                    "rightCheek",                    "leftEyeLower1", "leftEyeUpper1",                    "rightEyeLower1", "rightEyeUpper1",                    "leftEyebrowLower", //"leftEyebrowUpper",                    "rightEyebrowLower", //"rightEyebrowUpper",                    "lipsLowerInner", //"lipsLowerOuter",                    "lipsUpperInner", //"lipsUpperOuter",                ];                let points = [];                features.forEach( feature => {                    face.annotations[ feature ].forEach( x => {                        points.push( ( x[ 0 ] - x1 ) / bWidth );                        points.push( ( x[ 1 ] - y1 ) / bHeight );                    });                });                // Only grab the faces that are confident                if( face.faceInViewConfidence > 0.9 ) {                    trainingData.push({                        input: points,                        output: ferData[ setIndex ].emotion,                    });                }            });            setText( `${setIndex + 1}. Face Tracking Confidence: ${face.faceInViewConfidence.toFixed( 3 )} - ${ferData[ setIndex ].emotion}` );            setIndex++;            requestAnimationFrame( trackFace );        }        (async () => {            // Get FER-2013 data from the local web server            // https://www.kaggle.com/msambare/fer2013            // The data can be downloaded from Kaggle and placed inside the "web/fer2013" folder            // Get the lowest number of samples out of all emotion categories            const minSamples = Math.min( ...Object.keys( fer2013 ).map( em => fer2013[ em ].length ) );            Object.keys( fer2013 ).forEach( em => {                shuffleArray( fer2013[ em ] );                for( let i = 0; i < minSamples; i++ ) {                    ferData.push({                        emotion: em,                        file: fer2013[ em ][ i ]                    });                }            });            shuffleArray( ferData );            let canvas = document.getElementById( "output" );            canvas.width = OUTPUT_SIZE;            canvas.height = OUTPUT_SIZE;            output = canvas.getContext( "2d" );            output.translate( canvas.width, 0 );            output.scale( -1, 1 ); // Mirror cam            output.fillStyle = "#fdffb6";            output.strokeStyle = "#fdffb6";            output.lineWidth = 2;            // Load Face Landmarks Detection            model = await faceLandmarksDetection.load(                faceLandmarksDetection.SupportedPackages.mediapipeFacemesh            );            setText( "Loaded!" );            trackFace();        })();        </script>    </body></html>

2. Обнаружение эмоций на лице

Мы почти достигли своей цели. Применение модели обнаружения эмоций проще, чем её обучение. На этой веб-странице мы собираемся загрузить обученную модель TensorFlow и протестировать её на случайных лицах из набора данных FER.

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

let emotionModel = null;(async () => {    ...    // Load Face Landmarks Detection    model = await faceLandmarksDetection.load(        faceLandmarksDetection.SupportedPackages.mediapipeFacemesh    );    // Load Emotion Detection    emotionModel = await tf.loadLayersModel( 'web/model/facemo.json' );    ...})();

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

async function predictEmotion( points ) {    let result = tf.tidy( () => {        const xs = tf.stack( [ tf.tensor1d( points ) ] );        return emotionModel.predict( xs );    });    let prediction = await result.data();    result.dispose();    // Get the index of the maximum value    let id = prediction.indexOf( Math.max( ...prediction ) );    return emotions[ id ];}

Чтобы между тестовыми изображениями можно было делать паузу в несколько секунд, давайте создадим служебную функцию wait:

function wait( ms ) {    return new Promise( res => setTimeout( res, ms ) );}

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

async function trackFace() {    ...    let points = null;    faces.forEach( face => {        ...        // Add just the nose, cheeks, eyes, eyebrows & mouth        const features = [            "noseTip",            "leftCheek",            "rightCheek",            "leftEyeLower1", "leftEyeUpper1",            "rightEyeLower1", "rightEyeUpper1",            "leftEyebrowLower", //"leftEyebrowUpper",            "rightEyebrowLower", //"rightEyebrowUpper",            "lipsLowerInner", //"lipsLowerOuter",            "lipsUpperInner", //"lipsUpperOuter",        ];        points = [];        features.forEach( feature => {            face.annotations[ feature ].forEach( x => {                points.push( ( x[ 0 ] - x1 ) / bWidth );                points.push( ( x[ 1 ] - y1 ) / bHeight );            });        });    });    if( points ) {        let emotion = await predictEmotion( points );        setText( `${setIndex + 1}. Expected: ${ferData[ setIndex ].emotion} vs. ${emotion}` );    }    else {        setText( "No Face" );    }    setIndex++;    await wait( 2000 );    requestAnimationFrame( trackFace );}

Готово! Наш код должен начать определять эмоции на изображениях FER в соответствии с ожидаемой эмоцией. Попробуйте, и увидите, как он работает.

2. Финишная прямая

Взгляните на полный код применения обученной модели к изображениям из набора данных FER:
<html>    <head>        <title>Running - Recognizing Facial Expressions in the Browser with Deep Learning using TensorFlow.js</title>        <script src="http://personeltest.ru/aways/cdn.jsdelivr.net/npm/@tensorflow/tfjs@2.4.0/dist/tf.min.js"></script>        <script src="http://personeltest.ru/aways/cdn.jsdelivr.net/npm/@tensorflow-models/face-landmarks-detection@0.0.1/dist/face-landmarks-detection.js"></script>        <script src="web/fer2013.js"></script>    </head>    <body>        <canvas id="output"></canvas>        <img id="image" style="            visibility: hidden;            width: auto;            height: auto;            "/>        <h1 id="status">Loading...</h1>        <script>        function setText( text ) {            document.getElementById( "status" ).innerText = text;        }        async function setImage( url ) {            return new Promise( res => {                let image = document.getElementById( "image" );                image.src = url;                image.onload = () => {                    res();                };            });        }        function shuffleArray( array ) {            for( let i = array.length - 1; i > 0; i-- ) {                const j = Math.floor( Math.random() * ( i + 1 ) );                [ array[ i ], array[ j ] ] = [ array[ j ], array[ i ] ];            }        }        function drawLine( ctx, x1, y1, x2, y2, scale = 1 ) {            ctx.beginPath();            ctx.moveTo( x1 * scale, y1 * scale );            ctx.lineTo( x2 * scale, y2 * scale );            ctx.stroke();        }        function drawTriangle( ctx, x1, y1, x2, y2, x3, y3, scale = 1 ) {            ctx.beginPath();            ctx.moveTo( x1 * scale, y1 * scale );            ctx.lineTo( x2 * scale, y2 * scale );            ctx.lineTo( x3 * scale, y3 * scale );            ctx.lineTo( x1 * scale, y1 * scale );            ctx.stroke();        }        function wait( ms ) {            return new Promise( res => setTimeout( res, ms ) );        }        const OUTPUT_SIZE = 500;        const emotions = [ "angry", "disgust", "fear", "happy", "neutral", "sad", "surprise" ];        let ferData = [];        let setIndex = 0;        let emotionModel = null;        let output = null;        let model = null;        async function predictEmotion( points ) {            let result = tf.tidy( () => {                const xs = tf.stack( [ tf.tensor1d( points ) ] );                return emotionModel.predict( xs );            });            let prediction = await result.data();            result.dispose();            // Get the index of the maximum value            let id = prediction.indexOf( Math.max( ...prediction ) );            return emotions[ id ];        }        async function trackFace() {            // Set to the next training image            await setImage( ferData[ setIndex ].file );            const image = document.getElementById( "image" );            const faces = await model.estimateFaces( {                input: image,                returnTensors: false,                flipHorizontal: false,            });            output.drawImage(                image,                0, 0, image.width, image.height,                0, 0, OUTPUT_SIZE, OUTPUT_SIZE            );            const scale = OUTPUT_SIZE / image.width;            let points = null;            faces.forEach( face => {                // Draw the bounding box                const x1 = face.boundingBox.topLeft[ 0 ];                const y1 = face.boundingBox.topLeft[ 1 ];                const x2 = face.boundingBox.bottomRight[ 0 ];                const y2 = face.boundingBox.bottomRight[ 1 ];                const bWidth = x2 - x1;                const bHeight = y2 - y1;                drawLine( output, x1, y1, x2, y1, scale );                drawLine( output, x2, y1, x2, y2, scale );                drawLine( output, x1, y2, x2, y2, scale );                drawLine( output, x1, y1, x1, y2, scale );                // Add just the nose, cheeks, eyes, eyebrows & mouth                const features = [                    "noseTip",                    "leftCheek",                    "rightCheek",                    "leftEyeLower1", "leftEyeUpper1",                    "rightEyeLower1", "rightEyeUpper1",                    "leftEyebrowLower", //"leftEyebrowUpper",                    "rightEyebrowLower", //"rightEyebrowUpper",                    "lipsLowerInner", //"lipsLowerOuter",                    "lipsUpperInner", //"lipsUpperOuter",                ];                points = [];                features.forEach( feature => {                    face.annotations[ feature ].forEach( x => {                        points.push( ( x[ 0 ] - x1 ) / bWidth );                        points.push( ( x[ 1 ] - y1 ) / bHeight );                    });                });            });            if( points ) {                let emotion = await predictEmotion( points );                setText( `${setIndex + 1}. Expected: ${ferData[ setIndex ].emotion} vs. ${emotion}` );            }            else {                setText( "No Face" );            }            setIndex++;            await wait( 2000 );            requestAnimationFrame( trackFace );        }        (async () => {            // Get FER-2013 data from the local web server            // https://www.kaggle.com/msambare/fer2013            // The data can be downloaded from Kaggle and placed inside the "web/fer2013" folder            // Get the lowest number of samples out of all emotion categories            const minSamples = Math.min( ...Object.keys( fer2013 ).map( em => fer2013[ em ].length ) );            Object.keys( fer2013 ).forEach( em => {                shuffleArray( fer2013[ em ] );                for( let i = 0; i < minSamples; i++ ) {                    ferData.push({                        emotion: em,                        file: fer2013[ em ][ i ]                    });                }            });            shuffleArray( ferData );            let canvas = document.getElementById( "output" );            canvas.width = OUTPUT_SIZE;            canvas.height = OUTPUT_SIZE;            output = canvas.getContext( "2d" );            output.translate( canvas.width, 0 );            output.scale( -1, 1 ); // Mirror cam            output.fillStyle = "#fdffb6";            output.strokeStyle = "#fdffb6";            output.lineWidth = 2;            // Load Face Landmarks Detection            model = await faceLandmarksDetection.load(                faceLandmarksDetection.SupportedPackages.mediapipeFacemesh            );            // Load Emotion Detection            emotionModel = await tf.loadLayersModel( 'web/model/facemo.json' );            setText( "Loaded!" );            trackFace();        })();        </script>    </body></html>

Что дальше? Позволит ли это определять наши эмоции на лице?

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

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

Узнайте подробности, как получить Level Up по навыкам и зарплате или востребованную профессию с нуля, пройдя онлайн-курсы SkillFactory со скидкой 40% и промокодомHABR, который даст еще +10% скидки на обучение.

Подробнее..

Перевод Обнаружение эмоций на лице в реальном времени с помощью веб-камеры в браузере с использованием TensorFlow.js. Часть 3

03.03.2021 22:15:39 | Автор: admin

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


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

Добавление обнаружения эмоций на лице

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

Давайте загрузим и применим нашу предварительно обученную модель выражений на лице. Начала мы определим некоторые глобальные переменные для обнаружения эмоций, как мы делали раньше:

const emotions = [ "angry", "disgust", "fear", "happy", "neutral", "sad", "surprise" ];let emotionModel = null;

Затем мы можем загрузить модель обнаружения эмоций внутри блока async:

(async () => {    ...    // Load Face Landmarks Detection    model = await faceLandmarksDetection.load(        faceLandmarksDetection.SupportedPackages.mediapipeFacemesh    );    // Load Emotion Detection    emotionModel = await tf.loadLayersModel( 'web/model/facemo.json' );    ...})();

А для модельного прогнозирования по ключевым точкам лица мы можем добавить служебную функцию:

async function predictEmotion( points ) {    let result = tf.tidy( () => {        const xs = tf.stack( [ tf.tensor1d( points ) ] );        return emotionModel.predict( xs );    });    let prediction = await result.data();    result.dispose();    // Get the index of the maximum value    let id = prediction.indexOf( Math.max( ...prediction ) );    return emotions[ id ];}

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

async function trackFace() {    ...    let points = null;    faces.forEach( face => {        ...        // Add just the nose, cheeks, eyes, eyebrows & mouth        const features = [            "noseTip",            "leftCheek",            "rightCheek",            "leftEyeLower1", "leftEyeUpper1",            "rightEyeLower1", "rightEyeUpper1",            "leftEyebrowLower", //"leftEyebrowUpper",            "rightEyebrowLower", //"rightEyebrowUpper",            "lipsLowerInner", //"lipsLowerOuter",            "lipsUpperInner", //"lipsUpperOuter",        ];        points = [];        features.forEach( feature => {            face.annotations[ feature ].forEach( x => {                points.push( ( x[ 0 ] - x1 ) / bWidth );                points.push( ( x[ 1 ] - y1 ) / bHeight );            });        });    });    if( points ) {        let emotion = await predictEmotion( points );        setText( `Detected: ${emotion}` );    }    else {        setText( "No Face" );    }    requestAnimationFrame( trackFace );}

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

Вот полный код, нужный для завершения этого проекта
<html>    <head>        <title>Real-Time Facial Emotion Detection</title>        <script src="http://personeltest.ru/aways/cdn.jsdelivr.net/npm/@tensorflow/tfjs@2.4.0/dist/tf.min.js"></script>        <script src="http://personeltest.ru/aways/cdn.jsdelivr.net/npm/@tensorflow-models/face-landmarks-detection@0.0.1/dist/face-landmarks-detection.js"></script>    </head>    <body>        <canvas id="output"></canvas>        <video id="webcam" playsinline style="            visibility: hidden;            width: auto;            height: auto;            ">        </video>        <h1 id="status">Loading...</h1>        <script>        function setText( text ) {            document.getElementById( "status" ).innerText = text;        }        function drawLine( ctx, x1, y1, x2, y2 ) {            ctx.beginPath();            ctx.moveTo( x1, y1 );            ctx.lineTo( x2, y2 );            ctx.stroke();        }        async function setupWebcam() {            return new Promise( ( resolve, reject ) => {                const webcamElement = document.getElementById( "webcam" );                const navigatorAny = navigator;                navigator.getUserMedia = navigator.getUserMedia ||                navigatorAny.webkitGetUserMedia || navigatorAny.mozGetUserMedia ||                navigatorAny.msGetUserMedia;                if( navigator.getUserMedia ) {                    navigator.getUserMedia( { video: true },                        stream => {                            webcamElement.srcObject = stream;                            webcamElement.addEventListener( "loadeddata", resolve, false );                        },                    error => reject());                }                else {                    reject();                }            });        }        const emotions = [ "angry", "disgust", "fear", "happy", "neutral", "sad", "surprise" ];        let emotionModel = null;        let output = null;        let model = null;        async function predictEmotion( points ) {            let result = tf.tidy( () => {                const xs = tf.stack( [ tf.tensor1d( points ) ] );                return emotionModel.predict( xs );            });            let prediction = await result.data();            result.dispose();            // Get the index of the maximum value            let id = prediction.indexOf( Math.max( ...prediction ) );            return emotions[ id ];        }        async function trackFace() {            const video = document.querySelector( "video" );            const faces = await model.estimateFaces( {                input: video,                returnTensors: false,                flipHorizontal: false,            });            output.drawImage(                video,                0, 0, video.width, video.height,                0, 0, video.width, video.height            );            let points = null;            faces.forEach( face => {                // Draw the bounding box                const x1 = face.boundingBox.topLeft[ 0 ];                const y1 = face.boundingBox.topLeft[ 1 ];                const x2 = face.boundingBox.bottomRight[ 0 ];                const y2 = face.boundingBox.bottomRight[ 1 ];                const bWidth = x2 - x1;                const bHeight = y2 - y1;                drawLine( output, x1, y1, x2, y1 );                drawLine( output, x2, y1, x2, y2 );                drawLine( output, x1, y2, x2, y2 );                drawLine( output, x1, y1, x1, y2 );                // Add just the nose, cheeks, eyes, eyebrows & mouth                const features = [                    "noseTip",                    "leftCheek",                    "rightCheek",                    "leftEyeLower1", "leftEyeUpper1",                    "rightEyeLower1", "rightEyeUpper1",                    "leftEyebrowLower", //"leftEyebrowUpper",                    "rightEyebrowLower", //"rightEyebrowUpper",                    "lipsLowerInner", //"lipsLowerOuter",                    "lipsUpperInner", //"lipsUpperOuter",                ];                points = [];                features.forEach( feature => {                    face.annotations[ feature ].forEach( x => {                        points.push( ( x[ 0 ] - x1 ) / bWidth );                        points.push( ( x[ 1 ] - y1 ) / bHeight );                    });                });            });            if( points ) {                let emotion = await predictEmotion( points );                setText( `Detected: ${emotion}` );            }            else {                setText( "No Face" );            }            requestAnimationFrame( trackFace );        }        (async () => {            await setupWebcam();            const video = document.getElementById( "webcam" );            video.play();            let videoWidth = video.videoWidth;            let videoHeight = video.videoHeight;            video.width = videoWidth;            video.height = videoHeight;            let canvas = document.getElementById( "output" );            canvas.width = video.width;            canvas.height = video.height;            output = canvas.getContext( "2d" );            output.translate( canvas.width, 0 );            output.scale( -1, 1 ); // Mirror cam            output.fillStyle = "#fdffb6";            output.strokeStyle = "#fdffb6";            output.lineWidth = 2;            // Load Face Landmarks Detection            model = await faceLandmarksDetection.load(                faceLandmarksDetection.SupportedPackages.mediapipeFacemesh            );            // Load Emotion Detection            emotionModel = await tf.loadLayersModel( 'web/model/facemo.json' );            setText( "Loaded!" );            trackFace();        })();        </script>    </body></html>

Что дальше? Когда мы сможем носить виртуальные очки?

Взяв код из первых двух статей этой серии, мы смогли создать детектор эмоций на лице в реальном времени, используя лишь немного кода на JavaScript. Только представьте, что ещё можно сделать с помощью библиотеки TensorFlow.js! В следующей статье мы вернёмся к нашей цели создать фильтр для лица в стиле Snapchat, используя то, что мы уже узнали об отслеживании лиц и добавлении 3D-визуализации посредством ThreeJS. Оставайтесь с нами! До встречи завтра, в это же время!

Отслеживание лиц в реальном времени в браузере с использованием TensorFlow.js. Часть 2

Узнайте подробности, как получить Level Up по навыкам и зарплате или востребованную профессию с нуля, пройдя онлайн-курсы SkillFactory со скидкой 40% и промокодомHABR, который даст еще +10% скидки на обучение.

Подробнее..

Перевод Отслеживание лиц в реальном времени в браузере с использованием TensorFlow.js. Часть 4

04.03.2021 20:15:20 | Автор: admin

В 4 части (вы же прочли первую, вторую и третью, да?) мы возвращаемся к нашей цели создание фильтра для лица в стиле Snapchat, используя то, что мы уже узнали об отслеживании лиц и добавлении 3D-визуализации посредством ThreeJS. В этой статье мы собираемся использовать ключевые точки лица для виртуальной визуализации 3D-модели поверх видео с веб-камеры, чтобы немного развлечься с дополненной реальностью.


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

Добавление 3D-графики с помощью ThreeJS

Этот проект будет основан на коде проекта отслеживания лиц, который мы создали в начале этой серии. Мы добавим наложение 3D-сцены на исходное полотно.

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

В верхней части страницы нам нужно включить два файла скриптов, чтобы добавить ThreeJS и загрузчик файлов в формате GLTF для модели виртуальных очков, которую мы будем использовать:

<script src="http://personeltest.ru/aways/cdn.jsdelivr.net/npm/three@0.123.0/build/three.min.js"></script><script src="http://personeltest.ru/aways/cdn.jsdelivr.net/npm/three@0.123.0/examples/js/loaders/GLTFLoader.js"></script>

Чтобы упростить задачу и не беспокоиться о том, как поместить текстуру веб-камеры на сцену, мы можем наложить дополнительное прозрачное полотно (canvas) и нарисовать виртуальные очки на нём. Мы используем CSS-код, приведённый ниже над тегом body, поместив выходное полотно (output) в контейнер и добавив полотно наложения (overlay).

<style>    .canvas-container {        position: relative;        width: auto;        height: auto;    }    .canvas-container canvas {        position: absolute;        left: 0;        width: auto;        height: auto;    }</style><body>    <div class="canvas-container">        <canvas id="output"></canvas>        <canvas id="overlay"></canvas>    </div>    ...</body>

Для 3D-сцены требуется несколько переменных, и мы можем добавить служебную функцию загрузки 3D-модели для файлов GLTF:

<style>    .canvas-container {        position: relative;        width: auto;        height: auto;    }    .canvas-container canvas {        position: absolute;        left: 0;        width: auto;        height: auto;    }</style><body>    <div class="canvas-container">        <canvas id="output"></canvas>        <canvas id="overlay"></canvas>    </div>    ...</body>

Теперь мы можем инициализировать все компоненты нашего блока async, начиная с размера полотна наложения, как это было сделано с выходным полотном:

(async () => {    ...    let canvas = document.getElementById( "output" );    canvas.width = video.width;    canvas.height = video.height;    let overlay = document.getElementById( "overlay" );    overlay.width = video.width;    overlay.height = video.height;    ...})();

Также необходимо задать переменные renderer, scene и camera. Даже если вы не знакомы с трёхмерной перспективой и математикой камеры, вам не надо волноваться. Этот код просто располагает камеру сцены так, чтобы ширина и высота видео веб-камеры соответствовали координатам трёхмерного пространства:

(async () => {    ...    // Load Face Landmarks Detection    model = await faceLandmarksDetection.load(        faceLandmarksDetection.SupportedPackages.mediapipeFacemesh    );    renderer = new THREE.WebGLRenderer({        canvas: document.getElementById( "overlay" ),        alpha: true    });    camera = new THREE.PerspectiveCamera( 45, 1, 0.1, 2000 );    camera.position.x = videoWidth / 2;    camera.position.y = -videoHeight / 2;    camera.position.z = -( videoHeight / 2 ) / Math.tan( 45 / 2 ); // distance to z should be tan( fov / 2 )    scene = new THREE.Scene();    scene.add( new THREE.AmbientLight( 0xcccccc, 0.4 ) );    camera.add( new THREE.PointLight( 0xffffff, 0.8 ) );    scene.add( camera );    camera.lookAt( { x: videoWidth / 2, y: -videoHeight / 2, z: 0, isVector3: true } );    ...})();

Нам нужно добавить в функцию trackFace всего лишь одну строку кода для визуализации сцены поверх выходных данных отслеживания лица:

async function trackFace() {    const video = document.querySelector( "video" );    output.drawImage(        video,        0, 0, video.width, video.height,        0, 0, video.width, video.height    );    renderer.render( scene, camera );    const faces = await model.estimateFaces( {        input: video,        returnTensors: false,        flipHorizontal: false,    });    ...}

Последний этап этого ребуса перед отображением виртуальных объектов на нашем лице загрузка 3D-модели виртуальных очков. Мы нашли пару очков в форме сердца от Maximkuzlin на SketchFab. При желании вы можете загрузить и использовать другой объект.

Здесь показано, как загрузить объект и добавить его в сцену до вызова функции trackFace:

Размещение виртуальных очков на отслеживаемом лице

Теперь начинается самое интересное наденем наши виртуальные очки.

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

Необходимо сделать координату Y отрицательной, так как в системе координат двумерного экрана положительная ось Y направлена вниз, но в пространственной системе координат указывает вверх. Мы также вычтем из значения координаты Z расстояние или глубину камеры, чтобы получить правильное расстояния в сцене.

glasses.position.x = face.annotations.midwayBetweenEyes[ 0 ][ 0 ];glasses.position.y = -face.annotations.midwayBetweenEyes[ 0 ][ 1 ];glasses.position.z = -camera.position.z + face.annotations.midwayBetweenEyes[ 0 ][ 2 ];

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

Оценить направление вверх можно с помощью вектора из массива midwayBetweenEyes, использованного для очков, вместе с отслеживаемой точкой для нижней части носа. Затем нормируем его длину следующим образом:

glasses.up.x = face.annotations.midwayBetweenEyes[ 0 ][ 0 ] - face.annotations.noseBottom[ 0 ][ 0 ];glasses.up.y = -( face.annotations.midwayBetweenEyes[ 0 ][ 1 ] - face.annotations.noseBottom[ 0 ][ 1 ] );glasses.up.z = face.annotations.midwayBetweenEyes[ 0 ][ 2 ] - face.annotations.noseBottom[ 0 ][ 2 ];const length = Math.sqrt( glasses.up.x ** 2 + glasses.up.y ** 2 + glasses.up.z ** 2 );glasses.up.x /= length;glasses.up.y /= length;glasses.up.z /= length;

Чтобы получить относительный размер головы, можно вычислить расстояние между глазами:

const eyeDist = Math.sqrt(    ( face.annotations.leftEyeUpper1[ 3 ][ 0 ] - face.annotations.rightEyeUpper1[ 3 ][ 0 ] ) ** 2 +    ( face.annotations.leftEyeUpper1[ 3 ][ 1 ] - face.annotations.rightEyeUpper1[ 3 ][ 1 ] ) ** 2 +    ( face.annotations.leftEyeUpper1[ 3 ][ 2 ] - face.annotations.rightEyeUpper1[ 3 ][ 2 ] ) ** 2);

Наконец, мы масштабируем очки на основе значения eyeDist и ориентируем очки по оси Z, используя угол между вектором вверх и осью Y. И вуаля!

Выполните свой код и проверьте результат.

Прежде чем перейти к следующей части этой серии, давайте посмотрим на полный код, собранный вместе:

Простыня с кодом
<html>    <head>        <title>Creating a Snapchat-Style Virtual Glasses Face Filter</title>        <script src="http://personeltest.ru/aways/cdn.jsdelivr.net/npm/@tensorflow/tfjs@2.4.0/dist/tf.min.js"></script>        <script src="http://personeltest.ru/aways/cdn.jsdelivr.net/npm/@tensorflow-models/face-landmarks-detection@0.0.1/dist/face-landmarks-detection.js"></script>        <script src="http://personeltest.ru/aways/cdn.jsdelivr.net/npm/three@0.123.0/build/three.min.js"></script>        <script src="http://personeltest.ru/aways/cdn.jsdelivr.net/npm/three@0.123.0/examples/js/loaders/GLTFLoader.js"></script>    </head>    <style>        .canvas-container {            position: relative;            width: auto;            height: auto;        }        .canvas-container canvas {            position: absolute;            left: 0;            width: auto;            height: auto;        }    </style>    <body>        <div class="canvas-container">            <canvas id="output"></canvas>            <canvas id="overlay"></canvas>        </div>        <video id="webcam" playsinline style="            visibility: hidden;            width: auto;            height: auto;            ">        </video>        <h1 id="status">Loading...</h1>        <script>        function setText( text ) {            document.getElementById( "status" ).innerText = text;        }        function drawLine( ctx, x1, y1, x2, y2 ) {            ctx.beginPath();            ctx.moveTo( x1, y1 );            ctx.lineTo( x2, y2 );            ctx.stroke();        }        async function setupWebcam() {            return new Promise( ( resolve, reject ) => {                const webcamElement = document.getElementById( "webcam" );                const navigatorAny = navigator;                navigator.getUserMedia = navigator.getUserMedia ||                navigatorAny.webkitGetUserMedia || navigatorAny.mozGetUserMedia ||                navigatorAny.msGetUserMedia;                if( navigator.getUserMedia ) {                    navigator.getUserMedia( { video: true },                        stream => {                            webcamElement.srcObject = stream;                            webcamElement.addEventListener( "loadeddata", resolve, false );                        },                    error => reject());                }                else {                    reject();                }            });        }        let output = null;        let model = null;        let renderer = null;        let scene = null;        let camera = null;        let glasses = null;        function loadModel( file ) {            return new Promise( ( res, rej ) => {                const loader = new THREE.GLTFLoader();                loader.load( file, function ( gltf ) {                    res( gltf.scene );                }, undefined, function ( error ) {                    rej( error );                } );            });        }        async function trackFace() {            const video = document.querySelector( "video" );            output.drawImage(                video,                0, 0, video.width, video.height,                0, 0, video.width, video.height            );            renderer.render( scene, camera );            const faces = await model.estimateFaces( {                input: video,                returnTensors: false,                flipHorizontal: false,            });            faces.forEach( face => {                // Draw the bounding box                const x1 = face.boundingBox.topLeft[ 0 ];                const y1 = face.boundingBox.topLeft[ 1 ];                const x2 = face.boundingBox.bottomRight[ 0 ];                const y2 = face.boundingBox.bottomRight[ 1 ];                const bWidth = x2 - x1;                const bHeight = y2 - y1;                drawLine( output, x1, y1, x2, y1 );                drawLine( output, x2, y1, x2, y2 );                drawLine( output, x1, y2, x2, y2 );                drawLine( output, x1, y1, x1, y2 );                glasses.position.x = face.annotations.midwayBetweenEyes[ 0 ][ 0 ];                glasses.position.y = -face.annotations.midwayBetweenEyes[ 0 ][ 1 ];                glasses.position.z = -camera.position.z + face.annotations.midwayBetweenEyes[ 0 ][ 2 ];                // Calculate an Up-Vector using the eyes position and the bottom of the nose                glasses.up.x = face.annotations.midwayBetweenEyes[ 0 ][ 0 ] - face.annotations.noseBottom[ 0 ][ 0 ];                glasses.up.y = -( face.annotations.midwayBetweenEyes[ 0 ][ 1 ] - face.annotations.noseBottom[ 0 ][ 1 ] );                glasses.up.z = face.annotations.midwayBetweenEyes[ 0 ][ 2 ] - face.annotations.noseBottom[ 0 ][ 2 ];                const length = Math.sqrt( glasses.up.x ** 2 + glasses.up.y ** 2 + glasses.up.z ** 2 );                glasses.up.x /= length;                glasses.up.y /= length;                glasses.up.z /= length;                // Scale to the size of the head                const eyeDist = Math.sqrt(                    ( face.annotations.leftEyeUpper1[ 3 ][ 0 ] - face.annotations.rightEyeUpper1[ 3 ][ 0 ] ) ** 2 +                    ( face.annotations.leftEyeUpper1[ 3 ][ 1 ] - face.annotations.rightEyeUpper1[ 3 ][ 1 ] ) ** 2 +                    ( face.annotations.leftEyeUpper1[ 3 ][ 2 ] - face.annotations.rightEyeUpper1[ 3 ][ 2 ] ) ** 2                );                glasses.scale.x = eyeDist / 6;                glasses.scale.y = eyeDist / 6;                glasses.scale.z = eyeDist / 6;                glasses.rotation.y = Math.PI;                glasses.rotation.z = Math.PI / 2 - Math.acos( glasses.up.x );            });            requestAnimationFrame( trackFace );        }        (async () => {            await setupWebcam();            const video = document.getElementById( "webcam" );            video.play();            let videoWidth = video.videoWidth;            let videoHeight = video.videoHeight;            video.width = videoWidth;            video.height = videoHeight;            let canvas = document.getElementById( "output" );            canvas.width = video.width;            canvas.height = video.height;            let overlay = document.getElementById( "overlay" );            overlay.width = video.width;            overlay.height = video.height;            output = canvas.getContext( "2d" );            output.translate( canvas.width, 0 );            output.scale( -1, 1 ); // Mirror cam            output.fillStyle = "#fdffb6";            output.strokeStyle = "#fdffb6";            output.lineWidth = 2;            // Load Face Landmarks Detection            model = await faceLandmarksDetection.load(                faceLandmarksDetection.SupportedPackages.mediapipeFacemesh            );            renderer = new THREE.WebGLRenderer({                canvas: document.getElementById( "overlay" ),                alpha: true            });            camera = new THREE.PerspectiveCamera( 45, 1, 0.1, 2000 );            camera.position.x = videoWidth / 2;            camera.position.y = -videoHeight / 2;            camera.position.z = -( videoHeight / 2 ) / Math.tan( 45 / 2 ); // distance to z should be tan( fov / 2 )            scene = new THREE.Scene();            scene.add( new THREE.AmbientLight( 0xcccccc, 0.4 ) );            camera.add( new THREE.PointLight( 0xffffff, 0.8 ) );            scene.add( camera );            camera.lookAt( { x: videoWidth / 2, y: -videoHeight / 2, z: 0, isVector3: true } );            // Glasses from https://sketchfab.com/3d-models/heart-glasses-ef812c7e7dc14f6b8783ccb516b3495c            glasses = await loadModel( "web/3d/heart_glasses.gltf" );            scene.add( glasses );            setText( "Loaded!" );            trackFace();        })();        </script>    </body></html>

Что дальше? Что если также добавить обнаружение эмоций на лице?

Поверите ли, что всё это возможно на одной веб-странице? Добавив 3D-объекты к функции отслеживания лиц в реальном времени, мы сотворили волшебство с помощью камеры прямо в веб-браузере. Вы можете подумать: Но очки в форме сердца существуют в реальной жизни И это правда! А что, если мы создадим что-то действительно волшебное, например шляпу которая знает, что мы чувствуем?

Давайте в следующей статье создадим волшебную шляпу (как в Хогвартсе!) для обнаружения эмоций и посмотрим, сможем ли мы сделать невозможное возможным, ещё больше используя библиотеку TensorFlow.js! До встречи завтра, в это же время.

Узнайте подробности, как получить Level Up по навыкам и зарплате или востребованную профессию с нуля, пройдя онлайн-курсы SkillFactory со скидкой 40% и промокодомHABR, который даст еще +10% скидки на обучение.

Другие профессии и курсы
Подробнее..

Перевод Отслеживание лиц в реальном времени в браузере с использованием TensorFlow.js. Часть 5

06.03.2021 20:20:06 | Автор: admin

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

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


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

Создание волшебной шляпы

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

Чтобы создать нашу виртуальную шляпу, мы собираемся добавить графические ресурсы на веб-страницу как скрытые элементы img:

<img id="hat-angry" src="web/hats/angry.png" style="visibility: hidden;" /><img id="hat-disgust" src="web/hats/disgust.png" style="visibility: hidden;" /><img id="hat-fear" src="web/hats/fear.png" style="visibility: hidden;" /><img id="hat-happy" src="web/hats/happy.png" style="visibility: hidden;" /><img id="hat-neutral" src="web/hats/neutral.png" style="visibility: hidden;" /><img id="hat-sad" src="web/hats/sad.png" style="visibility: hidden;" /><img id="hat-surprise" src="web/hats/surprise.png" style="visibility: hidden;" />

Ключевое свойство этого проекта заключается в том, что шляпа должна отображаться всё время, в правильном положении и с правильным размером, поэтому мы сохраним состояния шляпы в глобальной переменной:

let currentEmotion = "neutral";let hat = { scale: { x: 0, y: 0 }, position: { x: 0, y: 0 } };

Рисовать шляпу этого размера и в этом положении мы будем с помощью 2D-преобразования полотна в каждом кадре.

async function trackFace() {    ...    output.drawImage(        video,        0, 0, video.width, video.height,        0, 0, video.width, video.height    );    let hatImage = document.getElementById( `hat-${currentEmotion}` );    output.save();    output.translate( -hatImage.width / 2, -hatImage.height / 2 );    output.translate( hat.position.x, hat.position.y );    output.drawImage(        hatImage,        0, 0, hatImage.width, hatImage.height,        0, 0, hatImage.width * hat.scale, hatImage.height * hat.scale    );    output.restore();    ...}

По ключевым точкам лица, предоставляемым TensorFlow, мы можем рассчитать размер и положение шляпы относительно лица, чтобы задать указанные выше значения.

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

const eyeDist = Math.sqrt(    ( face.annotations.leftEyeUpper1[ 3 ][ 0 ] - face.annotations.rightEyeUpper1[ 3 ][ 0 ] ) ** 2 +    ( face.annotations.leftEyeUpper1[ 3 ][ 1 ] - face.annotations.rightEyeUpper1[ 3 ][ 1 ] ) ** 2 +    ( face.annotations.leftEyeUpper1[ 3 ][ 2 ] - face.annotations.rightEyeUpper1[ 3 ][ 2 ] ) ** 2);const faceScale = eyeDist / 80;let upX = face.annotations.midwayBetweenEyes[ 0 ][ 0 ] - face.annotations.noseBottom[ 0 ][ 0 ];let upY = face.annotations.midwayBetweenEyes[ 0 ][ 1 ] - face.annotations.noseBottom[ 0 ][ 1 ];const length = Math.sqrt( upX ** 2 + upY ** 2 );upX /= length;upY /= length;hat = {    scale: faceScale,    position: {        x: face.annotations.midwayBetweenEyes[ 0 ][ 0 ] + upX * 100 * faceScale,        y: face.annotations.midwayBetweenEyes[ 0 ][ 1 ] + upY * 100 * faceScale,    }};

После сохранения названия спрогнозированной эмоции в currentEmotion отображается соответствующее изображение шляпы, и мы готовы её примерить!

if( points ) {    let emotion = await predictEmotion( points );    setText( `Detected: ${emotion}` );    currentEmotion = emotion;}else {    setText( "No Face" );}
Вот полный код этого проекта
<html>    <head>        <title>Building a Magical Emotion Detection Hat</title>        <script src="http://personeltest.ru/aways/cdn.jsdelivr.net/npm/@tensorflow/tfjs@2.4.0/dist/tf.min.js"></script>        <script src="http://personeltest.ru/aways/cdn.jsdelivr.net/npm/@tensorflow-models/face-landmarks-detection@0.0.1/dist/face-landmarks-detection.js"></script>    </head>    <body>        <canvas id="output"></canvas>        <video id="webcam" playsinline style="            visibility: hidden;            width: auto;            height: auto;            ">        </video>        <h1 id="status">Loading...</h1>        <img id="hat-angry" src="web/hats/angry.png" style="visibility: hidden;" />        <img id="hat-disgust" src="web/hats/disgust.png" style="visibility: hidden;" />        <img id="hat-fear" src="web/hats/fear.png" style="visibility: hidden;" />        <img id="hat-happy" src="web/hats/happy.png" style="visibility: hidden;" />        <img id="hat-neutral" src="web/hats/neutral.png" style="visibility: hidden;" />        <img id="hat-sad" src="web/hats/sad.png" style="visibility: hidden;" />        <img id="hat-surprise" src="web/hats/surprise.png" style="visibility: hidden;" />        <script>        function setText( text ) {            document.getElementById( "status" ).innerText = text;        }        function drawLine( ctx, x1, y1, x2, y2 ) {            ctx.beginPath();            ctx.moveTo( x1, y1 );            ctx.lineTo( x2, y2 );            ctx.stroke();        }        async function setupWebcam() {            return new Promise( ( resolve, reject ) => {                const webcamElement = document.getElementById( "webcam" );                const navigatorAny = navigator;                navigator.getUserMedia = navigator.getUserMedia ||                navigatorAny.webkitGetUserMedia || navigatorAny.mozGetUserMedia ||                navigatorAny.msGetUserMedia;                if( navigator.getUserMedia ) {                    navigator.getUserMedia( { video: true },                        stream => {                            webcamElement.srcObject = stream;                            webcamElement.addEventListener( "loadeddata", resolve, false );                        },                    error => reject());                }                else {                    reject();                }            });        }        const emotions = [ "angry", "disgust", "fear", "happy", "neutral", "sad", "surprise" ];        let emotionModel = null;        let output = null;        let model = null;        let currentEmotion = "neutral";        let hat = { scale: { x: 0, y: 0 }, position: { x: 0, y: 0 } };        async function predictEmotion( points ) {            let result = tf.tidy( () => {                const xs = tf.stack( [ tf.tensor1d( points ) ] );                return emotionModel.predict( xs );            });            let prediction = await result.data();            result.dispose();            // Get the index of the maximum value            let id = prediction.indexOf( Math.max( ...prediction ) );            return emotions[ id ];        }        async function trackFace() {            const video = document.querySelector( "video" );            const faces = await model.estimateFaces( {                input: video,                returnTensors: false,                flipHorizontal: false,            });            output.drawImage(                video,                0, 0, video.width, video.height,                0, 0, video.width, video.height            );            let hatImage = document.getElementById( `hat-${currentEmotion}` );            output.save();            output.translate( -hatImage.width / 2, -hatImage.height / 2 );            output.translate( hat.position.x, hat.position.y );            output.drawImage(                hatImage,                0, 0, hatImage.width, hatImage.height,                0, 0, hatImage.width * hat.scale, hatImage.height * hat.scale            );            output.restore();            let points = null;            faces.forEach( face => {                const x1 = face.boundingBox.topLeft[ 0 ];                const y1 = face.boundingBox.topLeft[ 1 ];                const x2 = face.boundingBox.bottomRight[ 0 ];                const y2 = face.boundingBox.bottomRight[ 1 ];                const bWidth = x2 - x1;                const bHeight = y2 - y1;                // Add just the nose, cheeks, eyes, eyebrows & mouth                const features = [                    "noseTip",                    "leftCheek",                    "rightCheek",                    "leftEyeLower1", "leftEyeUpper1",                    "rightEyeLower1", "rightEyeUpper1",                    "leftEyebrowLower", //"leftEyebrowUpper",                    "rightEyebrowLower", //"rightEyebrowUpper",                    "lipsLowerInner", //"lipsLowerOuter",                    "lipsUpperInner", //"lipsUpperOuter",                ];                points = [];                features.forEach( feature => {                    face.annotations[ feature ].forEach( x => {                        points.push( ( x[ 0 ] - x1 ) / bWidth );                        points.push( ( x[ 1 ] - y1 ) / bHeight );                    });                });                const eyeDist = Math.sqrt(                    ( face.annotations.leftEyeUpper1[ 3 ][ 0 ] - face.annotations.rightEyeUpper1[ 3 ][ 0 ] ) ** 2 +                    ( face.annotations.leftEyeUpper1[ 3 ][ 1 ] - face.annotations.rightEyeUpper1[ 3 ][ 1 ] ) ** 2 +                    ( face.annotations.leftEyeUpper1[ 3 ][ 2 ] - face.annotations.rightEyeUpper1[ 3 ][ 2 ] ) ** 2                );                const faceScale = eyeDist / 80;                let upX = face.annotations.midwayBetweenEyes[ 0 ][ 0 ] - face.annotations.noseBottom[ 0 ][ 0 ];                let upY = face.annotations.midwayBetweenEyes[ 0 ][ 1 ] - face.annotations.noseBottom[ 0 ][ 1 ];                const length = Math.sqrt( upX ** 2 + upY ** 2 );                upX /= length;                upY /= length;                hat = {                    scale: faceScale,                    position: {                        x: face.annotations.midwayBetweenEyes[ 0 ][ 0 ] + upX * 100 * faceScale,                        y: face.annotations.midwayBetweenEyes[ 0 ][ 1 ] + upY * 100 * faceScale,                    }                };            });            if( points ) {                let emotion = await predictEmotion( points );                setText( `Detected: ${emotion}` );                currentEmotion = emotion;            }            else {                setText( "No Face" );            }                        requestAnimationFrame( trackFace );        }        (async () => {            await setupWebcam();            const video = document.getElementById( "webcam" );            video.play();            let videoWidth = video.videoWidth;            let videoHeight = video.videoHeight;            video.width = videoWidth;            video.height = videoHeight;            let canvas = document.getElementById( "output" );            canvas.width = video.width;            canvas.height = video.height;            output = canvas.getContext( "2d" );            output.translate( canvas.width, 0 );            output.scale( -1, 1 ); // Mirror cam            output.fillStyle = "#fdffb6";            output.strokeStyle = "#fdffb6";            output.lineWidth = 2;            // Load Face Landmarks Detection            model = await faceLandmarksDetection.load(                faceLandmarksDetection.SupportedPackages.mediapipeFacemesh            );            // Load Emotion Detection            emotionModel = await tf.loadLayersModel( 'web/model/facemo.json' );            setText( "Loaded!" );            trackFace();        })();        </script>    </body></html>

Что дальше? Возможен ли контроль по состоянию глаз и рта?

В этом проекте собраны воедино все куски, созданные ранее в этой серии статей в целях развлечения с визуальными образами. А что, если бы можно было реализовать в нём взаимодействие с лицом?

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

Узнайте подробности, как получить Level Up по навыкам и зарплате или востребованную профессию с нуля, пройдя онлайн-курсы SkillFactory со скидкой 40% и промокодомHABR, который даст еще +10% скидки на обучение.

Другие профессии и курсы
Подробнее..

Перевод Мы создали Web приложение для определения лиц и масок для Google Chrome

15.03.2021 10:06:02 | Автор: admin

Введение

Основная цель - обнаружение лица и маски в браузере, не используя бэкенд на Python. Это простое приложение WebApp / SPA, которое содержит только JS-код и может отправлять некоторые данные на серверную часть для следующей обработки. Но начальное обнаружение лица и маски выполняется на стороне браузера и никакой реализации Python для этого не требуется.

На данный момент приложение работает только в браузере Chrome.

В следующей статье я опишу более технические подробности реализации на основе наших исследований.

Есть 2 подхода, как можно это реализовать в браузере:

Оба они поддерживают WASM, WebGL и CPU бэкенд. Но мы будем сравнивать только WASM и WebGL, так как производительность на CPU очень низкая и не может быть использована в конечной реализации.

Демо тут

TensorFlow.js

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

Больше информации о BlazeFace можно найти тут.

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

WASM (размер изображения для определения лица: 160x120px; размер изображения для определения маски: 64x64px)

WebGL (размер изображения для определения лица: 160x120px; размер изображения для определения маски: 64x64px)

Результаты производительности: получение кадров

Мы можем получать кадры с помощью соответствующих функций HTML API. Но и этот процесс занимает время. Поэтому нам нужно понимать, сколько времени мы потратим на такие действия. Ниже приведены соответствующие временные метрики.

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

Метод grabFrame() из интерфейса ImageCapture позволяет нам взять текущий кадр из видео в MediaStreamTrack и вернуть Promise, который вернет нам изображение в формате ImageBitmap.

Одиночный режим - это максимальная производительность при получении кадра. Режим синхронизации - это то, как часто мы можем получать кадр с распознаванием лиц.

Результаты производительности: определения лица и маски

Цветовая схема: < 6 fps красный, 7-12 fps оранжевый, 13-18 fps желтый, 19+ fps зеленый.

Результаты:

Мы не учитываем временные метрики при запуске приложения. Очевидно, что во время запуска приложения и первых запусков модели оно будет потреблять больше ресурсов и времени. Когда приложение находится в "теплом" режиме, только тогда стоит получать показатели производительности. Теплый режим в нашем случае - это просто позволить приложению проработать 5-10 секунд, а затем получать показатели производительности.

Возможные неточности в собранных временных показателях - до 50мс.

Модель BlazeFace была разработана специально для мобильных устройств и помогает достичь хорошей производительности при использовании TFLite на платформах Android и IOS (~ 50-200 FPS).

Больше информации тут.

Набор данных для переобучения модели с нуля недоступен (исследовательская группа Google не поделилась им).

BlazeFace содержит 2 модели:

  • Фронтальную камеру: размер изображения 128 x 128px, быстрее, но с меньшей точностью.

  • Задняя камера: размер изображения 256 x 256px, медленнее, но с высокой точностью.

Размер изображения

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

Изображение, используемое BlazeFace для распознавания лиц, имеет размер 128 x 128px. Исходное изображение будет изменено до этого размера с учетом его пропорций. Изображение, которое используется для обнаружения маски, имеет размер 64 x 64px.

Мы выбрали минимальные разрешения для обоих изображений с учетом требований к производительности и результатам. Такие минимальные изображения показали наилучшие результаты на ПК и мобильных устройствах. Мы используем изображения размером 64 x 64px для обнаружения маски, потому что размера 32 x 32px недостаточно для обнаружения маски с достаточной точностью.

Как получить лучшие изображения для анализа приложением?

Используя TensorFlow.js у нас есть следующие опции для получения лучшего изображения, чтоб использовать в приложении:

  • BlazeFace позволяет настроить точность определения лица. Мы установили этот показатель с высоким значением (> 0,9), чтобы избежать неожиданных положительных результатов (например "призраки" в темном помещении или затылок человека определяло как лицо).

  • BlazeFace позволяет настроить максимальное количество лиц, которые возвращаются после анализа изображения. Можно указать для этого параметра значение 1, чтобы вернуть только 1 лицо; можно установить значение 2 и более и в этом случае при обнаружении более 1-го лица сообщать о том, что один человек / лицо может находиться перед камерой.

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

Такие проверки могут быть следующими:

  1. Область лица должна находиться в калибровочной рамке в X%.

  2. Все ориентиры или их часть должны быть в калибровочной рамке.

  3. Ширина и высота области лица должны быть достаточными для дальнейшего анализа

  4. Проверки ширины / высоты области лица и ориентиров выполняются очень быстро на стороне клиента с помощью JS.

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

Размер моделей определения маски

Для определения маски мы использовали MobileNetV2 и MobileNetV3 с различными типами и мультипликаторами.

Мы предлагаем использовать легкие или сверхлегкие модели с TensorFlow.js в браузере (<3Mb). Основная причина в том, что WASM работает быстрее с такими моделями. Это указано в официальной документации, а также подтверждено нашими тестами производительности.

Дополнительные ресурсы

  • WASM JS бэкенд: ~60Kb

  • OpenCV.js: 1.6Mb

  • Наше SPA приложение (+TensorFlow.js): ~500Kb

  • BlazeFace модель: 466Kb

Для веб приложений время до взаимодействия (TTI) с ~3.5Mb JavaScript кода и бинарниками + JSON модели размером 1.5Mb to 6Mb будет >10 сек в холодном режиме; в прогретом режиме ожидается TTI - 4-5 сек.

Если использовать Web Worker (и OpenCV.js будет только в воркерах), это значительно уменьшит размер основного приложения до 800-900Kb. TTI будет 7-8 секунд в холодном режиме; в прогретом режиме <5 сек.

Возможные подходы к запуску моделей нейронных сетей в браузере

Однопоточная реализация

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

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

Использование Web Workers для запуска моделей в разном контексте и распараллеливание в браузере

В основном потоке JS запускается модель BlazeFace для обнаружения лиц, а обнаружение маски выполняется в отдельном потоке через Web Worker. Благодаря такой реализации мы можем разделить обе запущенные модели и ввести параллельную обработку кадров в браузере. Это положительно повлияет на общее восприятие UX в приложении. Веб-воркеры будут загружать библиотеки TensorFlow.js и OpenCV.js, основной поток JS - только TensorFlow.js. Это означает, что основное приложение будет запускаться намного быстрее, и тем самым мы сможем значительно сократить время TTI в браузере. Обнаружение лиц будет запускаться чаще, это увеличит FPS процесса обнаружения лиц. В результате процесс обнаружения маски будет запускаться чаще, а также будет увеличиваться FPS этого процесса. Ожидаемые улучшения до ~ 20%. Это означает, что указанные в статье выше FPS и миллисекунды могут быть улучшены на это значение.

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

Мы реализовали такой подход в приложении, и он работает. Но у нас есть некоторые технические проблемы с обратным вызовом postMessage, когда веб-воркер отправляет сообщение обратно в основной поток. По каким-то причинам он вводит дополнительную задержку (до 200мс на мобильных устройствах), которая убивает улучшение производительности, которого мы достигли с помощью распараллеливания (эта проблема актуальна только для чистого JS, после реализации на React.js эта проблема исчезла).

Результаты реализации с Web Workers

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

Тестовые параметры:mobileNetVersion=V3mobileNetVersionMultiplier = 0.75mobileNetVersionType = float16thumbnailSize=32pxbackend = wasm

В приложении мы запускаем BlazeFace в основном потоке и модель обнаружения маски в веб-воркере.

Измерения времени обработки Web Worker на разных устройствах:

Приведенные выше результаты демонстрируют, что по некоторым причинам время для отправки обратного вызова из Web Worker в основной поток зависит от модели, работающей или использующей метод TensorFlow browser.fromPixels в этом Web Worker. Если он запущен с моделью, обратный вызов в Mac OS отправляется ~ 27мс, если модель не запущена - 5мс. Эта разница в 22мс для Mac OS может привести к задержкам 100300мс на более слабых устройствах и повлияет на общую производительность приложения при использовании Web Worker. В настоящее время мы не понимаем, почему это происходит.

Как повысить точность и уменьшить количество ложных срабатываний?

Нам нужно иметь больше контекста, чтобы принять соответствующее решение, чтобы сообщить об обнаружении лица и отсутствии маски. Такой контекст - это ранее обработанные кадры и их состояние (лицо есть или нет, лицо есть или нет, маска есть или нет). Мы реализовали плавающий сегмент для управления таким дополнительным контекстом. Длина сегмента настраивается и зависит от текущего FPS, но в любом случае она не должна быть больше 200300мс, чтобы не было видимых задержек. Ниже приведена соответствующая схема, описывающая идею:

Заключение:

Чем мощнее устройство, тем лучше результаты производительности при измерении:

Для ПК мы могли получить следующие показатели:

  • Определение лица: > 30fps

  • Определение лица + определение маски: до 45fps

Для мобильных устройств мы могли получить следующие показатели:

  • Определение лица: от 2.5fps до 12-15fps в зависимости от мобильного устройства

  • Определение лица + определение маски: от 2 до 12fps в зависимости от мобильного устройства

  1. Обращаем внимание, что видео всегда в реальном времени и его производительность зависит от самого устройства, но всегда будет от 30 кадров в секунду.

  2. Для модели обнаружения маски в большинстве случаев лучшие результаты демонстрирует модель MobileNetV2 0.35, типы не влияют на метрики производительности.

  3. Размер модели обнаружения маски зависит от типов. Поскольку типы не влияют на показатели производительности, рекомендуется использовать модели uint16 или float16, чтобы иметь меньший размер модели в браузере и более быстрый TTI.

  4. Среда выполнения WASM показывает лучшие результаты по сравнению с WebGL для модели BlazeFace. Это соответствует официальной документации TensorFlow.js относительно производительности небольших моделей (<3Mb):

    Для большинства моделей серверная часть WebGL по-прежнему будет превосходить серверную часть WASM, однако WASM может быть быстрее для сверхлегких моделей (менее 3Mb и 60млн операций умножения и добавления). В этом случае преимущества распараллеливания GPU перевешиваются фиксированными накладными расходами на выполнение шейдеров WebGL.

  5. Время TTI всегда лучше для моделей WASM по сравнению с WebGL с той же конфигурацией моделей.

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

  7. На стороне клиента ожидаемый размер приложения составляет ~ 3,5Mb JS кода со всеми зависимостями, 466Kb модели BlazeFace, от 1,1Mb до 5,6Mb модели обнаружения маски. Ожидаемое время TTI для приложения составляет > 10 секунд для мобильных устройств в холодном режиме; в теплом режиме - ~ 5сек.

  8. При использовании веб-воркеров OpenCV.js может быть загружен только в веб-воркеры, это значительно снижает TTI для основного приложения.

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

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

  11. На наш взгляд, текущему решению достаточно даже 4-5 кадров в секунду, чтобы обеспечить хорошее восприятие пользователем UX. Но важно не показывать на экране область лица или ориентиры, а управлять всем на видео / экране:

    • Выделяя на экране, когда мы обнаружили человека.

    • Информирование о маске / отсутствии маски с помощью текстовых сообщений или другого выделения экрана.

  12. При таком взаимодействии пользователя задержки между реальным временем и нашими метаданными на экране будут составлять 200300мс. Такие значения будут рассматриваться пользователями системы как некритические задержки.

Подробнее..

TensorFlow.js Часть 1 Использование Low-Level API для аппроксимации линейной функций

07.08.2020 12:20:56 | Автор: admin
В настоящее время Python занимает доминирующую позицию для машинного обучения. Однако, если вы являетесь JS-разработчиком и заинтересованы окунуться в этот мир, то не обязательно включать в свой арсенал новый язык программирования, в связи с появлением TensorFlow.js


Преимущества использования TensorFlow.js в браузере
интерактивность браузер имеет много инструментов для визуализации происходящих процессов (графики, анимация и др.);
сенсоры браузер имеет прямой доступ к сенсорам устройства (камера, GPS, акселерометр и др.);
- защищенность данных пользователя нет необходимости отправлять обрабатываемые данные на сервер;
совместимость с моделями, созданными на Python.

Производительность
Одним из главных вопросов встает вопрос производительности.
В связи с тем, что машинное обучение это, по сути, выполнение различного рода математических операций с матрично-подобными данными (тензорами), то библиотека для такого рода вычислений в браузере использует WebGL. Это значительно увеличивает производительность, если бы те же операции осуществлялись на чистом JS. Естественно, библиотека имеет fallback на тот случай, если WebGL по каким-то причинам не поддерживается в браузере (на момент написания статьи caniuse показывает, что поддержка WebGL есть у 97.94% пользователей).
Для повышения производительности на Node.js используется native-binding с TensorFlow. Тут в качестве акселераторов могут служить CPU, GPU и TPU (Tensor Processing Unit)

Архитектура TensorFlow.js
1. Lowest Layer этот слой ответственен за параллелизацию вычислений при совершении математических операций над тензорами.
2. The Ops API предоставляет АПИ для осуществления математических операций над тензорами.
3. Layers API позволяет создавать сложные модели нейронных сетей с использованием разных видов слоев (dense, convolutional). Этот слой похож на API Keras на Python и имеет возможность загружать предварительно обученные сети на базе Keras Python.


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


Формализация решения
Ядром любого машинного обучения будет являться модель, в нашем случае это уравнение линейной функции:

$y=kx+b$


Исходя из условия, мы также имеем набор экспериментальных точек:

$(x^{(0)}_{t},y^{(0)}_{t} ), (x^{(1)}_{t}, y^{(1)}_{t}), ...(x^{(N)}_{t},y^{(N)}_{t})$


Предположим, что на $j$-ом шаге обучения были вычислены следующие коэффициенты линейного уравнения $k^{(j)}, b^{(j)}$. Сейчас нам необходимо математически выразить на сколько точны подобранные коэффициенты. Для этого нам необходимо посчитать ошибку (loss), которую можно определить, например, по среднеквадратичному отклонению. Tensorflow.js предлагает набор наиболее часто используемых loss функций: tf.metrics.meanAbsoluteError, tf.metrics.meanSquaredError и др.

$L(k^{(j)},b^{(j)})=\sum_{i=1}^{N} (y^{(i)}_{predicted} - y^{(i)}_{t})^{2}=\sum_{i=1}^{N}((k^{(j)}\cdot x^{(i)}+b^{(j)})-y^{(i)}_{t})$


Цель аппроксимации минимизация функции ошибки $L$. Воспользуемся для этого методом градиентного спуска. Необходимо:
найти вектор-градиент, вычисляя частные производные по коэффициентам $k^{(j)}, b^{(j)}$;
откорректировать коэффициенты уравнения в направлении обратном направлению вектора-градиента. Таким образом, мы будет минимизировать функцию ошибки:

$k^{(j+1)}=k^{(j)} - \mu \bigtriangledown_{k^{(j)}} L(k^{(j)},b^{(j)})=k^{(j)} - \mu\frac{\partial L(k^{(j)},b^{(j)}) }{\partial k^{(j)}};$


$b^{(j+1)}=b^{(j)} - \mu \bigtriangledown_{b^{(j)}} L(k^{(j)},b^{(j)})=b^{(j)} - \mu\frac{\partial L(k^{(j)},b^{(j)}) }{\partial b^{(j)}};$


где $\mu$ шаг обучения (learning rate) и является одним из настраиваемых параметров модели. Для градиентного спуска он не изменяется на протяжении всего процесса обучения. Маленькое значение learning rate может приводить к долгой сходимости процесса обучения модели и возможному попаданию в локальный минимум (рисунок 2), а сильно большое может приводить к бесконечному увеличению значения ошибки на каждом шагу обучения, рисунок 1.




Рисунок 1: Большое значение обучающего шага (learning-rate) Рисунок 2: Маленькое значение обучающего шага (learning-rate)


Как это реализовать без Tensorflow.js
Например, вычисление значения loss-функции (среднеквадратичное отклонение) выглядело бы так:
function loss(ysPredicted, ysReal) {    const squaredSum = ysPredicted.reduce(        (sum, yPredicted, i) => sum + (yPredicted - ysReal[i]) ** 2,        0);    return squaredSum / ysPredicted.length;}

Однако, количество входных данных может быть велико. Во время обучения модели нам надо на каждой итерации считать не только значение loss-функции, но и производить более серьезные операции вычисление градиента. Поэтому, есть смысл использовать tensorflow, который оптимизирует вычисления за счет использования WebGL. Более того, код становится значительно выразительнее, сравните:
    function loss(ysPredicted, ysReal) => {        const ysPredictedTensor = tf.tensor(ysPredicted);        const ysRealTensor = tf.tensor(ysReal);        const loss = ysPredictedTensor.sub(ysRealTensor).square().mean();        return loss.dataSync()[0];    };


Решение с помощью TensorFlow.js
Хорошая новость в том, что нам не придется заниматься написанием оптимизаторов для заданной функции ошибки (loss), мы не будем разрабатывать численные методы вычисления частных производных, за нас уже реализовали алгоритм обратного распространения ошибки (backpropogation). Нам лишь потребуется выполнить следующие шаги:
задать модель (линейную функцию, в нашем случае);
описать функцию ошибки (в нашем случае, это среднеквадратичное отклонение)
выбрать один из реализованных оптимизаторов (есть возможность расширить библиотеку собственной реализацией)

Что такое тензор
Абсолютно каждый сталкивался с тензорами в математике это скаляр, вектор, 2D матрица, 3D матрица. Тензор это обобщенное понятие всего перечисленного. Это контейнер данных, который содержит однородные по типу данные (tensorflow поддерживает int32, float32, bool, complex64, string) и имеет определенную форму (количество осей (ранк) и количество элементов в каждой из осей). Ниже мы рассмотрим тензоры вплоть до 3D-матриц, но так как это обобщение, количество осей у тензора может быть столько сколько угодно: 5D, 6D,...ND.
TensorFlow имеет следующий АПИ для создания тензора:
tf.tensor(values, shape?, dtype?)

где shape форма тензора и задается массивом, в котором количество элементов это количество осей, а каждое значение массива определяет количество элементов вдоль каждой из осей. Например, для задания матрицы размером 4x2 (4 строки, 2 колонки), форма примет вид [4, 2].
Визуализация Описание

Скаляр
Ранк: 0
Форма: []
JS структура:
2

TensorFlow API:
tf.scalar(2);tf.tensor(2, []);


Вектор
Ранк: 1
Форма: [4]
JS структура:
[1, 2, 3, 4]

TensorFlow API:
tf.tensor([ 1, 2, 3, 4]);tf.tensor([1, 2, 3, 4], [4]);tf.tensor1d([1, 2, 3, 4]);

Матрица
Ранк: 2
Форма: [4,2]
JS структура:
[    [1, 2],     [3, 4],     [5, 6],     [7, 8]]

TensorFlow API:
tf.tensor([[1, 2],[3, 4],[5,6],[7,8]]);tf.tensor([1, 2, 3, ... 7, 8], [4,2]);tf.tensor2d([[1, 2],[3, 4]...[7,8]]);tf.tensor2d([1, 2, 3, ... 7, 8], [4,2]);

Матрица
Ранк: 3
Форма:[4,2,3]
JS структура:
[    [ [ 1,  2], [ 3,  4], [ 5,  6] ],    [ [ 7,  8], [ 9, 10], [11, 12] ],    [ [13, 14], [15, 16], [17, 18] ],    [ [19, 20], [21, 22], [23, 24] ]]

TensorFlow API:
tf.tensor([     [ [ 1,  2], [ 3,  4], [ 5,  6] ],    ....    [ [19, 20], [21, 22], [23, 24] ] ]);tf.tensor([1, 2, 3, .... 24], [4, 2 ,3])



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


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

import * as tf from '@tensorflow/tfjs';export default class AbstractRegressionModel {    constructor(        width,        height,        optimizerFunction = tf.train.sgd,        maxEpochPerTrainSession = 100,        learningRate = 0.1,        expectedLoss = 0.001    ) {        this.width = width;        this.height = height;        this.optimizerFunction = optimizerFunction;        this.expectedLoss = expectedLoss;        this.learningRate = learningRate;        this.maxEpochPerTrainSession = maxEpochPerTrainSession;        this.initModelVariables();        this.trainSession = 0;        this.epochNumber = 0;        this.history = [];    }}

Итак, в конструкторе модели мы определили width и height это реальная ширина и высота плоскости, на котором мы будем расставлять экспериментальные точки. Это необходимо для нормализации входные данные. Т.е. если у нас $x\in[0, width], y\in[0, height]$, то после нормализации мы будем иметь: $x_{norm}\in[0, 1], y_{norm}\in[0, 1]$
optimizerFunction сделаем задание оптимизатора гибким, для того чтобы была возможность попробовать другие имеющиеся в библиотеке оптимизаторы, по умолчанию мы задали метод Стохастического градиентного спуска tf.train.sgd. Порекомендовал бы также поиграться с другими доступными оптимизаторами, которые во время обучения могут подстраивать learningRate и процесс обучения значительно улучшается, например, попробуйте следующие оптимизаторы: tf.train.momentum, tf.train.adam.
Для того чтобы процесс обучения не был бесконечен мы определили два параметра maxEpochPerTrainSesion и expectedLoss таким образом мы прекратим процесс обучения или при достижении максимального числа обучающих итераций, или когда значение функции-ошибки станет ниже ожидаемой ошибки (все учтем в методе train ниже).
В конструкторе мы вызываем метод initModelVariables но как и договаривались, мы ставим заглушку и определим его в дочернем классе позже.
initModelVariables() {    throw Error('Model variables should be defined')}


Сейчас реализуем основной метод модели train:
/**     * Train model until explicitly stop process via invocation of stop method     * or loss achieve necessary accuracy, or train achieve max epoch value     *     * @param x - array of x coordinates     * @param y - array of y coordinates     * @param callback - optional, invoked after each training step     */    async train(x, y, callback) {        const currentTrainSession = ++this.trainSession;        this.lossVal = Number.POSITIVE_INFINITY;        this.epochNumber = 0;        this.history = [];        // convert array into tensors        const input = tf.tensor1d(this.xNormalization(x));        const output = tf.tensor1d(this.yNormalization(y));        while (            currentTrainSession === this.trainSession            && this.lossVal > this.expectedLoss            && this.epochNumber <= this.maxEpochPerTrainSession            ) {            const optimizer = this.optimizerFunction(this.learningRate);            optimizer.minimize(() => this.loss(this.f(input), output));            this.history = [...this.history, {                epoch: this.epochNumber,                loss: this.lossVal            }];            callback && callback();            this.epochNumber++;            await tf.nextFrame();        }    }

trainSession это по сути уникальный идентификатор сессии обучения на тот случай, если внешний АПИ будет вызывать train метод, при том что предыдущая сессия обучения еще не завершилась.
Из кода вы видите, что мы из одномерных массивов создаем tensor1d, при этом данные необходимо предварительно нормализовать, функции для нормализации тут:
xNormalization = xs => xs.map(x => x / this.width);yNormalization = ys => ys.map(y => y / this.height);yDenormalization = ys => ys.map(y => y * this.height);

В цикле для каждого шага обучения мы вызываем оптимизатор модели, которому необходимо передать loss функцию. Как и договаривались, loss-функция у нас будет задана среднеквадратичным отклонением. Тогда пользуясь АПИ tensorflow.js имеем:
    /**     * Calculate loss function as mean-square deviation     *     * @param predictedValue - tensor1d - predicted values of calculated model     * @param realValue - tensor1d - real value of experimental points     */    loss = (predictedValue, realValue) => {        // L = sum ((x_pred_i - x_real_i)^2) / N        const loss = predictedValue.sub(realValue).square().mean();        this.lossVal = loss.dataSync()[0];        return loss;    };

Процесс обучения продолжается пока
не будет достигнут лимит по количеству итераций
не будет достигнута желаемая точность ошибки
не начат новый обучающий процесс
Также обратите внимание как вызвана loss-функция. Для получения predictedValue мы вызываем функцию f которая по сути и будет задавать форму, по которой будет производится регрессия, а в абстрактном классе как и договаривались ставим заглушку:
  f(x) {        throw Error('Model should be defined')  }

На каждом шаге обучения в свойстве объекта модели history мы сохраняем динамику изменения ошибки на каждой эпохе обучения.

После процесса обучения модели мы должны иметь метод, который принимал бы входные параметры и выдавал вычисленные выходные параметры используя обученную модель. Для этого в АПИ мы определили predict метод и выглядит он так:
/**     * Predict value basing on trained model     *  @param x - array of x coordinates     *  @return Array({x: integer, y: integer}) - predicted values associated with input     *     * */    predict(x) {        const input = tf.tensor1d(this.xNormalization(x));        const output = this.yDenormalization(this.f(input).arraySync());        return output.map((y, i) => ({ x: x[i], y }));    }

Обратите внимание на arraySync, по аналогии как node.js, если есть arraySync метод, то однозначно есть и асинхронный метод array, который возвращает Promise. Promise тут нужен, потому что как мы говорили ранее, тензоры все мигрируют в WebGL для ускорения вычислений и процесс становится асинхронным, потому что надо время для перемещения данных с WebGL в JS переменную.
Мы закончили с абстрактным классом, полную версию кода вы можете посмотреть тут:
AbstractRegressionModel.js
import * as tf from '@tensorflow/tfjs';export default class AbstractRegressionModel {        constructor(        width,        height,        optimizerFunction = tf.train.sgd,        maxEpochPerTrainSession = 100,        learningRate = 0.1,        expectedLoss = 0.001    ) {        this.width = width;        this.height = height;        this.optimizerFunction = optimizerFunction;        this.expectedLoss = expectedLoss;        this.learningRate = learningRate;        this.maxEpochPerTrainSession = maxEpochPerTrainSession;        this.initModelVariables();        this.trainSession = 0;        this.epochNumber = 0;        this.history = [];    }    initModelVariables() {        throw Error('Model variables should be defined')    }    f() {        throw Error('Model should be defined')    }    xNormalization = xs => xs.map(x => x / this.width);    yNormalization = ys => ys.map(y => y / this.height);    yDenormalization = ys => ys.map(y => y * this.height);    /**     * Calculate loss function as mean-squared deviation     *     * @param predictedValue - tensor1d - predicted values of calculated model     * @param realValue - tensor1d - real value of experimental points     */    loss = (predictedValue, realValue) => {        const loss = predictedValue.sub(realValue).square().mean();        this.lossVal = loss.dataSync()[0];        return loss;    };    /**     * Train model until explicitly stop process via invocation of stop method     * or loss achieve necessary accuracy, or train achieve max epoch value     *     * @param x - array of x coordinates     * @param y - array of y coordinates     * @param callback - optional, invoked after each training step     */    async train(x, y, callback) {        const currentTrainSession = ++this.trainSession;        this.lossVal = Number.POSITIVE_INFINITY;        this.epochNumber = 0;        this.history = [];        // convert data into tensors        const input = tf.tensor1d(this.xNormalization(x));        const output = tf.tensor1d(this.yNormalization(y));        while (            currentTrainSession === this.trainSession            && this.lossVal > this.expectedLoss            && this.epochNumber <= this.maxEpochPerTrainSession            ) {            const optimizer = this.optimizerFunction(this.learningRate);            optimizer.minimize(() => this.loss(this.f(input), output));            this.history = [...this.history, {                epoch: this.epochNumber,                loss: this.lossVal            }];            callback && callback();            this.epochNumber++;            await tf.nextFrame();        }    }    stop() {        this.trainSession++;    }    /**     * Predict value basing on trained model     *  @param x - array of x coordinates     *  @return Array({x: integer, y: integer}) - predicted values associated with input     *     * */    predict(x) {        const input = tf.tensor1d(this.xNormalization(x));        const output = this.yDenormalization(this.f(input).arraySync());        return output.map((y, i) => ({ x: x[i], y }));    }}


Для линейной регрессии определим новый класс, который будет унаследован от абстрактного класса, где нам надо определить только два метода initModelVariables и f.
Так как мы работаем над линейной аппроксимацией, то мы должны задать две переменные k, b и они будут тензорами-скалярами. Для оптимизатора мы должны указать, что они являются настраиваемыми (переменными), а в качестве начальных значений присвоим произвольные числа.
initModelVariables() {   this.k = tf.scalar(Math.random()).variable();   this.b = tf.scalar(Math.random()).variable();}

Тут следует рассмотреть АПИ для variable:
tf.variable (initialValue, trainable?, name?, dtype?)

Следует обратить внимание на второй аргумент trainable булевая переменная и по умолчанию она true. Она используется оптимизаторами, что говорит им необходимо ли при минимизации loss-функции конфигурировать данную переменную. Это может быть полезным, когда мы строим новую модель на базе предварительно обученной модели, загруженной с Keras Python, и мы уверены, что переобучать некоторые слои в этой модели нет необходимости.
Далее нам необходимо определить уравнение аппроксимирующей функции использующей tensorflow API, взгляните на код и вы интуитивно поймете как использовать его:
f(x) {   // y = kx + b   return  x.mul(this.k).add(this.b);}

Например, таким образом вы можете задать квадратичную аппроксимацию:
initModelVariables() {   this.a = tf.scalar(Math.random()).variable();   this.b = tf.scalar(Math.random()).variable();   this.c = tf.scalar(Math.random()).variable();}f(x) {    // y = ax^2 + bx + c    return this.a.mul(x.square()).add(this.b.mul(x)).add(this.c);}


Здесь вы можете ознакомится с моделями для линейной и квадратичной регрессий:
LinearRegressionModel.js
import * as tf from '@tensorflow/tfjs';import AbstractRegressionModel from "./AbstractRegressionModel";export default class LinearRegressionModel extends AbstractRegressionModel {    initModelVariables() {        this.k = tf.scalar(Math.random()).variable();        this.b = tf.scalar(Math.random()).variable();    }    f = x => x.mul(this.k).add(this.b);}


QuadraticRegressionModel.js
import * as tf from '@tensorflow/tfjs';import AbstractRegressionModel from "./AbstractRegressionModel";export default class QuadraticRegressionModel extends AbstractRegressionModel {    initModelVariables() {        this.a = tf.scalar(Math.random()).variable();        this.b = tf.scalar(Math.random()).variable();        this.c = tf.scalar(Math.random()).variable();    }    f = x => this.a.mul(x.square()).add(this.b.mul(x)).add(this.c);}



Ниже приведен код, написанный на React, который использует написанную модель линейной регресси и создает UX для пользователя:
Regression.js
import React, { useState, useEffect } from 'react';import Canvas from './components/Canvas';import LossPlot from './components/LossPlot_v3';import LinearRegressionModel from './model/LinearRegressionModel';import './RegressionModel.scss';const WIDTH = 400;const HEIGHT = 400;const LINE_POINT_STEP = 5;const predictedInput = Array.from({ length: WIDTH / LINE_POINT_STEP + 1 })    .map((v, i) => i * LINE_POINT_STEP);const model = new LinearRegressionModel(WIDTH, HEIGHT);export default () => {    const [points, changePoints] = useState([]);    const [curvePoints, changeCurvePoints] = useState([]);    const [lossHistory, changeLossHistory] = useState([]);    useEffect(() => {        if (points.length > 0) {            const input = points.map(({ x }) => x);            const output = points.map(({ y }) => y);            model.train(input, output, () => {                changeCurvePoints(() => model.predict(predictedInput));                changeLossHistory(() => model.history);            });        }    }, [points]);    return (        <div className="regression-low-level">            <div className="regression-low-level__top">                <div className="regression-low-level__workarea">                    <div className="regression-low-level__canvas">                        <Canvas                            width={WIDTH}                            height={HEIGHT}                            points={points}                            curvePoints={curvePoints}                            changePoints={changePoints}                        />                    </div>                    <div className="regression-low-level__toolbar">                        <button                            className="btn btn-red"                            onClick={() => model.stop()}>Stop                        </button>                        <button                            className="btn btn-yellow"                            onClick={() => {                                model.stop();                                changePoints(() => []);                                changeCurvePoints(() => []);                            }}>Clear                        </button>                    </div>                </div>                <div className="regression-low-level__loss">                    <LossPlot                              loss={lossHistory}/>                </div>            </div>        </div>    )}



Результат:


Я бы настоятельно порекомендовал выполнить следующие задания:
реализовать аппроксимацию функции по логарифмической функции
для tf.train.sgd оптимизатора попробуйте поиграться с learningRate и понаблюдать как изменяется процесс обучения. Попробуйте задать очень большое значение learningRate, чтобы получить картину, приведенной на рисунке 2
задайте оптимизатор tf.train.adam. Улучшился ли обучающий процесс. Зависит ли обучающий процесс от изменении learningRate значения в конструкторе модели.
Подробнее..

Машинное обучение. Нейронные сети (часть 2) Моделирование OR XOR с помощью TensorFlow.js

25.08.2020 22:13:11 | Автор: admin
Статья является продолжением цикла статей, посвященных машинному обучению с использованием библиотеки TensorFlow.JS, в предыдущей статье приведены общая теоретическая часть обучения простейшей нейронной сети, состоящей из одного нейрона:
Машинное обучение. Нейронные сети (часть 1): Процесс обучения персептрона

В данной же статье мы с помощью нейронной сети смоделируем выполнение логических операций OR; XOR, которые являются своеобразным Hello World приложением для нейронных сетей.
В статье будет последовательно описан процесс такого моделирования с использованием TensorFlow.js.

Итак построим нейронную сеть для логической операции ИЛИ. На вход мы будем всегда подавать два сигнала X1 и X2, а на выходе будем получать один выходной сигнал Y. Для обучения нейронный сети нам также потребуется тренировочный набор данных (рисунок 1).

Рисунок 1 Тренировочный набор данных и модель для моделирования логической операции ИЛИ

Чтобы понять какую структуру нейронной сети задать, давайте представим тренировочный набор данных на координатной плоскости с осями X1 и X2 (рисунок 2, слева).

Рисунок 2 Тренировочный набор на координатной плоскости для логической операции ИЛИ

Обратите внимание, что для решения этой задачи нам достаточно провести линию, которая разделяла бы плоскость таким образом, чтобы по одну сторону линии были все TRUE значения, а по другую все FALSE значения (рисунок 2, справа). Мы также знаем, что с этой целью прекрасно может справиться один нейрон в нейронной сети (персептрон), выходное значение которое по входным сигналам вычисляется как:

$y=x_1w_1+x_2w_2$

что является математической записью уравнения прямой.

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

Рисунок 3 Нейронная сеть для обучения логической операции ИЛИ

Итак решим данную задачу с помощью TensorFlow.js
Для начала нам надо тренировочный набор данных преобразовать в тензоры. Тензор это контейнер данных, который может иметь $N$ осей и произвольное число элементов вдоль каждой из осей. Большинство с тензорами знакомы с математики векторы (тензор с одной осью), матрицы (тензор с двумя осями строки, колонки).
Для задания тренировочного набора данных первая ось (axis 0) это всегда ось вдоль которой располагаются все находящиеся в наличии экземпляры выборок данных (рисунок 4).

Рисунок 4 Структура тензора

В нашем конкретном случае мы имеем 4 экземпляра выборок данных (рисунок 1), значит входной тензор вдоль первой оси будет иметь 4 элемента. Каждый элемент тренировочной выборки представляет собой вектор, состоящий из двух элементов X1, X2. Таким образом, входной тензор имеет 2 оси (матрица), вдоль первой оси расположено 4 элемента, вдоль второй оси 2 элемента.
const input = [[0, 0], [1, 0], [0, 1], [1, 1]];const inputTensor = tf.tensor(input, [input.length, 2]);

Аналогично, преобразуем выходные данные в тензор. Как и для входных сигналов, вдоль первой оси имеем 4 элемента, а в каждом элементе располагается вектор, содержащий одно значение:
const output = [[0], [1], [1], [1]]const outputTensor = tf.tensor(output, [output.length, 1]);

Создадим модель, используя TensorFlow API:
const model = tf.sequential();model.add(      tf.layers.dense({ inputShape: [2], units: 1, activation: 'sigmoid' }));

Создание модели всегда будет начинаться с вызова tf.sequential(). Основным строительным блоком модели это слои. Мы можем подключать к модели столько слоев в нейронную сеть, сколько нам надо. Тут мы используем dense слой, что означает что каждый нейрон последующего слоя имеет связь с каждым нейроном предыдущего слоя. Например, если у нас есть два dense слоя, в первом слое $N$ нейронов, а во втором $M$, то общее число соединений между слоями будет $NM$.
В нашем случае как видим нейронная сеть состоит из одного слоя, в котором один нейрон, поэтому units задан единице.
Также для первого слоя нейронной сети мы обязательно должны задать inputShape, так как у нас каждый входной экземпляр представлен вектором из двух значений X1 и X2, поэтому inputShape=[2]. Обратите внимание, что задавать inputShape для промежуточных слоев нет необходимости TensorFlow может определить эту величину по значению units предыдущего слоя.
Также каждому слою в случае необходимости можно задать активационную функцию, мы определились выше, что это будет сигмоидная функция. Доступные на данных момент активационные функции в TensorFlow можно найти здесь.

Далее нам надо откомпилировать модель (см АПИ здесь), при этом нам надо задать два обязательных параметра это функция-ошибки и вид оптимизатора, который будет искать ее минимум:
model.compile({    optimizer: tf.train.sgd(0.1),    loss: 'meanSquaredError'});

Мы задали в качестве оптимизатора stochastic gradient descent с обучающим шагом 0.1.
Список реализованных оптимизаторов в библиотеке: tf.train.sgd, tf.train.momentum, tf.train.adagrad, tf.train.adadelta, tf.train.adam, tf.train.adamax, tf.train.rmsprop.
На практике по умолчанию сразу можно выбирать adam оптимизатор, который имеет лучшие показатели сходимости модели, в отличии от sgd обучающий шаг (learning rate) на каждом этапе обучения задается в зависимости от истории предыдущих шагов и не является постоянным на протяжении всего процесса обучения.

В качестве функции ошибки задана функцией среднеквадратичной ошибки:

$L=\frac{1}{N}\sum_{i=1}^{N}\left(y_{predicted(i)}-y_{expected(i)}\right)^2$


Модель задана, и следующим шагом является процесс обучения модели, для этого у модели должен быть вызван метод fit:
async function initModel() {    // skip for brevity    await model.fit(trainingInputTensor, trainingOutputTensor, {        epochs: 1000,        shuffle: true,        callbacks: {            onEpochEnd: async (epoch, { loss }) => {                // any actions on during any epoch of training                await tf.nextFrame();            }        }    })}

Мы задали, что процесс обучения должен состоять из 100 обучающих шагов (количество эпох обучений); также на каждой очередной эпохе входные данные следует перетасовать в произвольном порядке (shuffle=true) что ускорит процесс сходимости модели, так в нашем тренировочном наборе данных мало экземпляров (4).
После завершения процесса обучения мы можем использовать predict метод, который по новым входным сигналам, будет вычислять выходное значение.
const testInput = generateInputs(10);const testInputTensor = tf.tensor(testInput, [testInput.length, 2]);const output = model.predict(testInputTensor).arraySync();

Метод generateInputs просто создает набор тестовых данных с количеством элементов 10x10, которые делят координатную плоскость на 100 квадратов:

$[[0,0], [0, 0.1], [0, 0.2], . [1, 1]]$



Полный код приведен тут
import React, { useEffect, useState } from 'react';import LossPlot from './components/LossPlot';import Canvas from './components/Canvas';import * as tf from "@tensorflow/tfjs";let model;export default () => {    const [data, changeData] = useState([]);    const [lossHistory, changeLossHistory] = useState([]);    useEffect(() => {        async function initModel() {            const input = [[0, 0], [1, 0], [0, 1], [1, 1]];            const inputTensor = tf.tensor(input, [input.length, 2]);            const output = [[0], [1], [1], [1]]            const outputTensor = tf.tensor(output, [output.length, 1]);            const testInput = generateInputs(10);            const testInputTensor = tf.tensor(testInput, [testInput.length, 2]);            model = tf.sequential();            model.add(            tf.layers.dense({ inputShape:[2], units:1, activation: 'sigmoid'})            );            model.compile({                optimizer: tf.train.adam(0.1),                loss: 'meanSquaredError'            });            await model.fit(inputTensor, outputTensor, {                epochs: 100,                shuffle: true,                callbacks: {                    onEpochEnd: async (epoch, { loss }) => {                        changeLossHistory((prevHistory) => [...prevHistory, {                            epoch,                            loss                        }]);                        const output = model.predict(testInputTensor)                                                       .arraySync();                        changeData(() => output.map(([out], i) => ({                            out,                            x1: testInput[i][0],                            x2: testInput[i][1]                        })));                        await tf.nextFrame();                    }                }            })        }        initModel();    }, []);    return (        <div>            <Canvas data={data} squareAmount={10}/>            <LossPlot loss={lossHistory}/>        </div>    );}function generateInputs(squareAmount) {    const step = 1 / squareAmount;    const input = [];    for (let i = 0; i < 1; i += step) {        for (let j = 0; j < 1; j += step) {            input.push([i, j]);        }    }    return input;}


На следующем рисунке вы увидите частично процесс обучения:


Реализация в планкере:



Моделирование логической операции XOR
Тренировочный набор для данной функции приведен на рисунке 6, а также расставим эти точки также как делали для логической операции ИЛИ на координатной плоскости


Рисунок 6 Тренировочный набор данных и модель для моделирования логической операции ИСКЛЮЧАЮЩЕЕ ИЛИ (XOR)

Обратите внимание, что в отличии от логической операции ИЛИ вы не сможете разделить плоскость одной прямой линией, чтобы по одну сторону находились все TRUE значения, а по другую сторону все FALSE. Однако, мы это можем сделать с помощью двух кривых (рисунок 7).
Очевидно, что в данном случае одним нейроном в слое не обойтись нужен как минимум дополнительно еще один слой с двумя нейронами, каждый из которых определил бы одну из двух линий на плоскости.


Рисунок 7 Модель нейронной сети для логической операции ИСКЛЮЧАЮЩЕЕ ИЛИ (XOR)

В прошлом коде нам необходимо сделать изменения в нескольких местах, одни из которых это непосредственно сам тренировочный набор данных:
const input = [[0, 0], [1, 0], [0, 1], [1, 1]];const inputTensor = tf.tensor(input, [input.length, 2]);const output = [[0], [1], [1], [0]]const outputTensor = tf.tensor(output, [output.length, 1]);

Вторым местом это изменившаяся структура модели, согласно рисунку 7:
model = tf.sequential();model.add(    tf.layers.dense({ inputShape: [2], units: 2, activation: 'sigmoid' }));model.add(    tf.layers.dense({ units: 1, activation: 'sigmoid' }));

Процесс обучения в этом случае выглядит так:


Реализация в планкере:



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

TensorFlowJS использование обученных моделей без их модификаций в браузере

30.10.2020 12:22:53 | Автор: admin

При работе с TensorFlowJS можно выделить три направления его использования (рисунок 1):

  1. Использование обученных моделей без модификаций топологий и без их переобучений

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

  3. Создание и обучение моделей с нуля

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

1. Использование моделей через абстрактный АПИ

Этот тот случай, когда вам не потребуются какие-то глубокие знания по машинному обучению в принципе. Вам даже не придется производить никаких манипуляций с входными данными для модели. Например, если искомая модель принимает на вход изображение размерностью 240x240 пикселей, а вы хотели бы использовать изображение 20x30, то вам бы пришлось делать манипуляции над изображением, чтобы она была совместима с моделью. Также достаточно часто, модели требуют нормализацию входных данных (значения в одном цветом канале для пикселей изменяются от 0 до 256, однако для лучшей сходимости модели могут иногда требовать, чтобы величина каждого пиксела была в интервале [0, 1] или [-1, 1]).

Однако этот тип моделей абстрагирован таким образом, что единственным следом того, что внутри модели используется TensorFlowJS это будет только импорт этой библиотеки вместе с моделью.

Рисунок 2 Классификация моделей по типу решаемых задачРисунок 2 Классификация моделей по типу решаемых задач

На момент написания статьи (октябрь 2020), Google представил 13 официальных моделей для использования в открытом доступе. Весь список моделей может быть найден тут. В списке вы можете найти модели, решающие следующие типы задач (рисунок 2):

  • классификация изображений: MobileNet - классификация изображений между 1000 категорий, при этом на изображении должен находится объект одного класса с нейтральным фоном (ссылка)

  • обнаружение объектов на изображении с указанием пространственных координат: Coco-SSD определение объектов на изображении с указанием окна, в котором находится объект; модель может распознавать 80 разных категорий объектов (ссылка)

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

  • распознавание голосовых команд: SpeechCommands распознавание звуковых команд; в исходнос состоянии модель может распознавать команды из словаря из 20 слов на английском языке, например: 'go', 'stop', 'yes', 'no' (ссылка)

  • текстовая классификация: Toxicity определяет содержит ли сообщения не приемлемый контент, содержащий оскорбления, не уважение, непристойные выражения с сексуальным содержанием (ссылка)

  • текстовая классификация: Toxicity определяет содержит ли сообщения не приемлемый контент, содержащий оскорбления, не уважение, непристойные выражения с сексуальным содержанием (ссылка)
    - .

Давайте рассмотрим АПИ конкретной модели Coco-SSD и каким образом оно может быть использовано:

import * as tf from "@tensorflow/tfjs";import * as cocoSsd from "@tensorflow-models/coco-ssd";// other importsexport default () => {    const {videoRef} = useVideoStream();    const canvasRef = useRef<HTMLCanvasElement>(null);    const modelRef = useRef<ObjectDetection>();    function detectFrame() {        if (modelRef.current && canvasRef.current && videoRef.current) {            modelRef.current.detect(videoRef.current)                .then(objects => {                    buildObjectReactangle(canvasRef.current, objects);                    window.requestAnimationFrame(detectFrame);                });        }    }    useEffect(() => {        cocoSsd.load().then(model => {            modelRef.current = model;            detectFrame();        });    }, []);    return (        <div style={{position: 'relative'}}>            <video ref={videoRef} width={640} height={480}/>            <canvas ref={canvasRef} width={640} height={480}/>        </div>    );}

В первую очередь, необходимо сделать импорт модели и библиотеки @tensorflow/tfjs, этот тот единственный след, говорящий, что под капотом модель использует TensorFlowJS. При первом рендеринге React компонента необходимо загрузить модель: cocoSsd.load (строки 22-27). Как только модель будет загружена (это может занять некоторое время), можно запускать процесс обработки кадров, получаемые с видео потока вызовом detectFrame функции (строка 25). Для получения метаинформации о положении объектов на изображении достаточно вызвать метод model.detect, первым аргументом которого является ссылка на DOM элемент video (строка 14). Этот метод возвращает Promise, результатом которого является массив с метаинформацией о каждом объекте, который был определен моделью в следующем формате:

[{  bbox: [x, y, width, height],  class: "person",  score: 0.8380282521247864}, {  bbox: [x, y, width, height],  class: "kite",  score: 0.74644153267145157}]

Функция buildObjectReactngle отрисовывает области на canvas вокруг объектов, распознанных моделью. Canvas наложен на видео поток сверху.

Здесь ссылка на git-repository с полным кодом.

2. Использование сериализированных обученных моделей TensorFlow

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

В TensorFlow.js в АПИ есть специальный метод tf.GraphModel.save. Модель сохраняется в TensorFlowJS JSON формате. Это набор файлов model.json и один или более бинарных файлов (рисунок 3). Файл model.json содержит информацию о топологии сети и имеет исчерпывающую информации о классах, из которых состоит модель, а также конфигурации слоев модели. Бинарные файлы содержат значения весов всех слоев модели и если модель имеет большое число параметров, то они могут разбиваться на шарды, по умолчания каждый бинарный файл не более 4МБ.

Рисунок 3 Структура сохраненной модели в формате TensorFlow.js JSON.Рисунок 3 Структура сохраненной модели в формате TensorFlow.js JSON.

На самом деле TensorFlowJS может работать не только с моделями, которые были сохранены им же, но так же есть возможность работать с моделями, которые были обучены с помощью фреймворка Keras на языке Python, но сохраненных с помощью TensorFlow в формате SavedModel. Всего есть 3 типа формата сериализации моделей, которые вы сможете использовать с TensorFlowJS (рисунок 4):

  • TensorFlow SavedModel формат модели по умолчанию, с которыми модели сохраняются с помощь TensorFlow;

  • Keras Model модели, сохраняемые фреймворком Keras в формате HDF5;

  • TensorFlow Hub Model модели, которые распространяются через специальную платформу TensorFlow Hub.

Однако перед использование SavedModel, Keras Model, Tensorflow Hub model в TensorFlowJS необходимо конвертировать эти модели с помощью tfjs_converter.

Тут следует обратить внимание, что модели в форматах SavedModel и TensorFlow Hub могут быть конвертированы только в формат tfjs_graph_model. Модели же, сохраненные в формате Keras, могут конвертированы как в формат tfjs_graph_model, так и в формат tfjs_layers_model.

Чем же отличаются tfjs_layers_model от tfjs_graph_model?

Модели в формате tf_js_graph_model преобразуются в экземпляр класса tf.FrozenModel, что означает что все параметры модели зафиксированы и не подлежат изменению. Таким образом, если вы хотели бы переобучить модель с новой выборкой входных данных, которая более релевантна решаемой вами задачи, или же изменить топологию модели на базе существующей то вам такой формат модели не подойдет. Однако есть преимущество для этой модели - это что вычисление (inference time) будет значительно меньше, по сравнению с той же моделью, но преобразованной в формат tfjs_layers_model.

Модель в этом формате загружается с помощью следующего АПИ:

const model = tf.loadGraphModel('/path/to/model.json')

Если есть нужда в переобучении модели или изменении ее топологии на стороне браузера, то модель должна быть загружена в формате tfjs_layers_model. Модель в этом формате загружается с помощью следующего АПИ:

const model = tf.loadLayersModel('/path/to/model.json')

На рисунке 5 представлена сводная схема по вышесказанному.

Рисунок 5 Сводная таблица по конвертации сериализованных моделейРисунок 5 Сводная таблица по конвертации сериализованных моделей

Конвертор можно использовать прямо из командной строки или же его можно вызывать непосредственно в Python-скрипте.

Использование конвертора из командной строки

Для этого вам необходимо установить tensorflowjs:

$ pip  install tensorflowjs

Предположим, мы имеем Keras модель, сохраненная в HDF5 формате в tfjs_layers_model формат, для этого достаточно вызвать команду:

$ tensorflowjs_converter \       --input_format keras \      --output_format tfjs_layers_model \      path/to/my_model.h5 \      path/to/tfjs_target_dir 

После работы конвертора, в папке path/to/tfjs_target_dir вы увидите знакомые уже вам файлы model.json и и бинарные файлы содержащие значения весов модели.

Использование конвертора из Python-скрипта

Установите зависимости для скрипта:

$ pipenv keras tensorflowjs

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

import kerasimport tensorflowjs as tfjsmobileNet = keras.applications.mobilenet_v2.MobileNetV2()tfjs.converters.save_keras_model(mobileNet, './model/from_python_script')

Некоторые ошибки, с которыми вы можете столкнуться

После того, как вы конвертировали модель в tfjs_layers_model и вы пытаетесь загрузить модель на клиенте с помощью tf.loadLayersModel, то можете получить такого рода ошибку:

Uncaught (in promise) Error: Unknown layer: Functional. This may be due to one of the following reasons:1. The layer is defined in Python, in which case it needs to be ported to TensorFlow.js or your JavaScript code.2. The custom layer is defined in JavaScript, but is not registered properly with tf.serialization.registerClass().    at deserializeKerasObject (generic_utils.ts:242)    at deserialize (serialization.ts:31)    at loadLayersModelFromIOHandler (models.ts:294)    at async loadModel (index.js:9)

Если снова взгляните на рисунок 3, то увидите, что файл содержит описание классов, на базе которых строится модель. При этом мы видим что модель создана с помощь класса Functional. Вам надо обратить внимание на версию TF.js, которую вы используете и его АПИ. В данном случае использовался TF.js версии 2.0, и если вы посмотрите на АПИ, то увидите, что в АПИ этой версии нет класса tf.Functional. Именно тут скрывается и проблема. Как его можно разрешить:
-обновить версию TF.js до версии, где в АПИ представлен класс tf.Functional
-в связи с тем, что tf.Functional extends tf.LayersModel, то в model.json файле вместо Functional в поле class_name нужно использовать Model

Также можете посмотреть об ошибке на stackoverflow тут.

Как использовать загруженную модель?

В отличии от упакованных моделей в абстрактный АПИ, тут нам не избежать знакомства с TensorFlow, так как помимо загрузки модели, нам также необходимо позаботится о передачи входных данных в модель.

Итак, давайте просто используем предварительно конвертированную модель MobileNetV2 из формата Keras в формат tfjs_graph_model для классификации изображений.

После загрузки изображения, для того чтобы понять в каком формате нам надо передавать данные в модель, посмотрите файл model.json inputs поле:

"inputs": { "input_1:0": { "name": "input_1:0", "dtype": "DT_FLOAT", "tensorShape": { "dim": [ { "size": "-1"}, { "size": "224"}, { "size": "224"}, { "size": "3"} ] } }},

Таким образом на вход модель необходимо передать 4D-tensor размерностью [null, 224, 224, 3], что соответствует [EXAMPLE_BATCH, WIDTH, HEIGHT, COLOR_CHANNELS]. Вдоль первой оси может располагаться одновременно несколько изображений, но так как мы будем разрабатывать приложение, которое позволяет пользователю загрузить только одно изображение, то в нашем случае на вход модели всегда будем передавать тензор размерностью [1, 224, 224, 3].

Также взглянем, что модель нам выдаст в файле model.json в поле outputs:

"outputs": { "Identity:0": { "name": "Identity:0", "dtype": "DT_FLOAT", "tensorShape": { "dim": [ { "size": "-1" }, { "size": "1000" } ] } }}

Это 2D-тензор размерностью [null, 1000], где 1000 это количество классов, на которые наша сеть может классифицировать изображения. Это так называемый one-hot вектор, в котором все значения равны нулю, за исключением одного, например [0, 0, 1, 0, 0] это значит что нейронная сеть считает с вероятностью 1, что на изображении класс с индексом 2 (индексация как обычно начинается с нуля). Когда мы будет использовать модель, то этот вектор будет представлять собой распределение вероятности для каждого из классов, например, можно получить такие значения: [0.07, 0.1, 0.03, 0.75, 0.05]. Обратите внимание, что сумма всех значений будет равна 1, а модель считает с максимальной вероятностью 0.75, что это объект класса c индексом 3.

Теперь все готово. Полный код представлен тут:

import * as tf from '@tensorflow/tfjs';import {GraphModel, Tensor2D} from '@tensorflow/tfjs';// other dependenciesexport default () => {    const [model, setModel] = useState<GraphModel>();    const [image, setImage] = useState<ImageType>();    const [results, setResults] = useState<ResultType[]>([]);    useEffect(() => {        (async () => {            setModel(await tf.loadGraphModel('/mobileNetV2/model.json'));        })();    }, []);    useEffect(() => {        tf.tidy(() => {            if (image?.image && model) {                (async () => {                    const offset = tf.scalar(127.5);                    const input = tf.browser.fromPixels(image.image)                        // make image compatable with model input                        .resizeNearestNeighbor([224, 224])                         // feature scale tensor image to range [-1, 1]                        .sub(offset).div(offset)                         .toFloat()                        // adding batch axis and convert tensor with shape                        // [224, 224, 3] to [1, 224, 224, 3]                        .expandDims();                    const output = model.predict(input) as Tensor2D;                    const results = Array.from(await output.data())                        .map((item, i) => ({probability: item, label: labels[i]}))                        .sort((a1, a2) => a2.probability - a1.probability)                        .slice(0, 5);                    setResults(results);                })();            }        });    }, [image]);    return (        <div>            {model && <InputFile setImage={setImage}/>}            {image && image.src && <img src={image.src} alt={'Image'}/>}            {results && results.length > 0 && results.map((item, i) => (                <div key={i}>                    {item.label} with probability {item.probability.toFixed(5)}                </div>            ))}        </div>    );}

При рендеринге реакт-компонента, мы загружаем модель, которая была конвертирована с Keras (строки 10-14). Как только модель будет загружена пользователю откроется возможность загружать изображение для классификации.

Как только пользователь выберет изображение мы для начала должны преобразовать изображения в тензор размерностью [224, 224, 3], а так же необходимо нормализировать данные, чтобы значения пикселей было в промежутке [-1, 1]. Передадим этот тензор модели и получим выходной тензор (строка 30). Теперь нам надо всего лишь сопоставить текстовые метки с индексами, и вывести пять наиболее вероятных классов для загруженного изображения.

Обратите тут внимание, что функция обернута в tf.tidy это важная деталь. Для увеличения производительности, TensorFlow.js в браузере обычно вычисления производит с помощью WebGL, и к сожалению тут нет механизма сборщика мусора, который автоматически как-то мог бы понять, что некоторые тензоры уже не нужны и можно высвободить память с WebGL. Чтобы этот процесс не был ручным (потому что каждый тензор в своем АПИ имеет метод dispose, который высвобождает память), мы оборачиваем операции над тензором в tf.tidy и после исполнения функции выживут только те тензоры, которые возвращаются этой функцией, а все оставшиеся тензоры будут уничтожены. Так как мы ничего не возвращаем из функции - то все тензоры будут уничтожены после ее исполнения.

Полный код вы можете найти в git-repository тут

Подробнее..

Машинное обучение. Нейронные сети (часть 3) Convolutional Network под микроскопом. Изучение АПИ Tensorflow.js

18.09.2020 12:15:01 | Автор: admin

Смотрите также:

  1. Машинное обучение. Нейронные сети (часть 1): Процесс обучения персептрона

  2. Машинное обучение. Нейронные сети (часть 2): Моделирование OR, XOR с помощью TensorFlow.js

В предыдущих статьях, использовался только один из видов слоев нейронной сети полносвязанные (dense, fully-connected), когда каждый нейрон исходного слоя имеет связь со всеми нейронами из предыдущих слоев.

Чтобы обработать, например, черно-белое изображение размером 24x24, мы должны были бы превратить матричное представление изображения в вектор, который содержит 24x24 =576 элементов. Как можно вдуматься, с таким преобразованием мы теряем важный атрибут взаимное расположение пикселей в вертикальном и горизонтальном направлении осей, а также, наверное, в большинстве случаев пиксел, находящийся в верхнем левом углу изображения вряд ли имеет какое-то логически объяснимое влияние на пиксел в нижнем правом углу.

Для исключения этих недостатков для обработки изображений используют сверточные слои (convolutional layer, CNN).

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

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

Рисунок 1 Принцип работы последовательно подключенных сверточных слоев, с выделением характерных признаков на каждом из уровней. Каждый следующий из набора последовательно подключенных CNN слоев извлекает более сложные паттерны, базируясь на ранее выделанных.Рисунок 1 Принцип работы последовательно подключенных сверточных слоев, с выделением характерных признаков на каждом из уровней. Каждый следующий из набора последовательно подключенных CNN слоев извлекает более сложные паттерны, базируясь на ранее выделанных.

1. Сверточные слои нейронной сети (convolutional layer)

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

Предположим, что мы имеем фильтр размерностью 2x2 (матрица K) и он делает проекцию на исходное изображение, которая обязательно тоже имеет размерность 2x2 (матрица N), тогда значение выходного слоя вычисляется следующим образом:

\left[\begin{matrix}n_{11}&n_{12}\\n_{21}&n_{22}\\\end{matrix}\right]\ast\left[\begin{matrix}k_{11}&k_{12}\\k_{21}&k_{22}\\\end{matrix}\right]=n_{11}k_{11}+n_{12}k_{12}+n_{21}k_{21}+n_{22}k_{22}

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

Обратите внимание, что данное выражение имеет похожую форму записи операции суммирования в полносвязанные слоях (fully-connected, dense layers):

{sum=\ \vec{X}}^T\vec{W}=\sum_{i=1}^{n=4}{x_iw_i}=x_1w_1+x_2w_2+x_3w_3+x_4w_4

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

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

Рисунок 2 Вычисление внутри сверточных слоевРисунок 2 Вычисление внутри сверточных слоев

Размерность ядра фильтра (kernel size) обычно выбирают квадратной формы и с нечетным количеством элементов вдоль осей матрицы 3, 5, 7.

Если мы имеем форму ядра фильтра (kernel) [kh, kw], а входное изображение размерностью [nh, nw], то размерность выходного сверточного слоя будет (рисунок 3):

c_w=n_w-k_w+1; c_h=n_h-k_h+1Рисунок 3 Принцип формирования сверточного выходного слоя с размерностью ядра фильтра [3,3]Рисунок 3 Принцип формирования сверточного выходного слоя с размерностью ядра фильтра [3,3]

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

Чтобы избежать потери разрешений выходных изображений, в настройках сверточных слоев используют дополнительный параметр отступ (padding). Это расширяет исходное изображения по краям, заполняя эти ячейки нулевыми значениями. Предположим, что мы добавляем ph и pw ячеек к исходному изображению, тогда размер выходного сверточного слоя будет:

c_w=n_w+p_w-k_w+1; c_h=n_h+p_h-k_h+1

Обычно, размерность отступов задают таким образом, чтобы размерности исходного изображения и выходного сверточного слоя совпадали, тогда они могут быть вычислены следующим образом:

p_w=k_w-1; p_h=k_h-1Рисунок 4 Включение отступов в исходное изображения для сохранения той же размерности для выходного сверточного слояРисунок 4 Включение отступов в исходное изображения для сохранения той же размерности для выходного сверточного слоя

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

Рисунок 5 Передвижение сверточного слоя с шагом (stride) больше единицыРисунок 5 Передвижение сверточного слоя с шагом (stride) больше единицы

Предполагая, что размер шага вдоль горизонтальной и вертикальной осей равны соответственно sw, sh, тогда размер выходного сверточного слоя составит:

c_w=\left \lfloor (n_w+p_w-k_w+s_w)/s_w \right \rfloor; c_h=\left \lfloor (n_h+p_h-k_h+s_h)/s_h \right \rfloor

Так же стоит отметить, что сверточный слой может содержать один и более фильтров (каждый фильтр это аналог скрытого слоя с нейронами в полносвязанном слое нейронной сети). Каждый фильтр будет ответственен за извлечение из изображения своих специфичных паттернов (признаков). Представим, что на вход первого сверточного слоя (CONV1) было подано изображение размерностью 9x9x1 (изображение с одним цветовым каналом черно-белое изображение), а сверточный слой имеет 2 фильтра с шагом перемещения ядра 1x1 (stride) и отступ (padding) подобран таким образом, чтобы выходной слой сохранял туже размерность, что и входной. Тогда размерность выходного слоя будет 9x9x2 где 2 это количество фильтров (см. рисунок 6). На следующем шаге в CONV2 сверточном слое, обратите внимание, что при задании размерности фильтра 2x2, его глубина определяется глубиной входного слоя, которой равен 2, тогда ядро будет размерностью 2x2x2. Выходной слой после второго сверточного слоя (CONV2) тогда будет 9x9x4, где 4 количество фильтров в сверточном слое.

Рисунок 6 Изменение размерности тензоров после несколько последовательных сверточных слоев Рисунок 6 Изменение размерности тензоров после несколько последовательных сверточных слоев

Обратите тут внимание, что во всех фреймворках мы будем задавать kw и kh для фильтра, однако если при этом этот на сверточный слой было подано изображение размерностью nw x nh x nd, где nd - количество цветовых каналов изображения или же количество характеристик, извлеченных на предыдущем сверточном слое, то размерность фильтра будет kw x kh x nd (смотри рисунок 6, CONV2).

На рисунке 7 изображен подход при вычислениях, если на сверточный слой подано цветное изображение с тремя каналами RGB, а сверточный слой задан матрицей 3x3. Как было указано выше, так как глубина входного изображения равна трем (3 канала), то фильтр будет иметь размерность 3x3x3.

Риунок 7 - Вычисления в сверточном слое, если входное изображение имеет три канала RGBРиунок 7 - Вычисления в сверточном слое, если входное изображение имеет три канала RGB

АПИ TensorFlow.js для сверточного слоя

Для создания сверточного слоя, необходимо воспользоваться следующим методом: tf.layers.conv2d, который принимает один аргумент это объект параметров, среди которых важны к рассмотрению следующие:
- filter number число фильтров в слое

- kernelSize number | number[] размерность фильтра, если задана number, то размерность фильтра принимает квадратную форму, если задана массивом то высота и ширина могут отличаться

- strides number | number[] - шаг продвижения, не обязательный параметр и по умолчанию задан как [1,1], в горизонтальном и вертикальном направлениях окно фильтра будет продвигаться на одну ячейку.

- padding same, valid настройка нулевого отступа, по умолчанию valid

Рассмотрим режимы задания нулевого отступа.

Режим 'same'

Сверточный слой будет использовать нулевые отступы при необходимости, при котором выходной слой будет всегда иметь размерность, которая вычисляется делением входной ширины (длины) на шаг передвижения фильтра (stride) с округлением в большую сторону. Например, входная ширина слоя вдоль одной из осей - 11 ячеек, шаг перемещения окна фильтра 5, тогда выходной слой будет иметь размерность 13/5=2.6, с учетом округления в большое сторону это будет 3 (рисунок 8).

Рисунок 8 Режим работы valid и same для отступов в фреймворках при kernelSize=6 и strides=5.Рисунок 8 Режим работы valid и same для отступов в фреймворках при kernelSize=6 и strides=5.

В случае если stride=1, то размерность выходного слоя будет равна размерности входного слоя (рисунок 9), фреймворк добавит столько нулевых отступов, сколько необходимо (рисунок 8).

Рисунок 9 Режим работы valid и same для отступов в фреймворке при kernelSize=3 и strides=1Рисунок 9 Режим работы valid и same для отступов в фреймворке при kernelSize=3 и strides=1

Режим 'valid'

В этом режиме фреймворк не добавляет нулевые отступы, в котором в зависимости от strides будут игнорироваться некоторые ячейки входного изображения, как показано на рисунке 8.

Практическое использование сверточного слоя в TensorFlow.js

Давайте ближе познакомимся с АПИ и попробуем к загруженному изображению применить сверточный слой, фильтр которого извлекают из изображения вертикальные или горизонтальные линии. Эти фильтры имеют следующий вид:

- для извлечения вертикальных линий:

\left[\begin{matrix}1&0&-1\\1&0&-1\\1&0&-1\\\end{matrix}\right]

- для извлечения горизонтальных линий:

\left[\begin{matrix}1&1&1\\0&0&0\\-1&-1&-1\\\end{matrix}\right]

В первую очередь понадобится АПИ, который преобразует загруженное изображение в тензор, для этого будет использован tf.browser.fromPixels. Первым аргументом является источник изображения, это может быть указатель на img или canvas элемент в разметке.

<img src="./sources/itechart.png" alt="Init image" id="target-image"/><canvas id="output-image-01"></canvas><script>   const imgSource = document.getElementById('target-image');   const image = tf.browser.fromPixels(imgSource, 1);</script>

Далее, соберем модель, которая состоит из одного сверточного слоя, содержащей один фильтр размерностью 3x3, с режимом отступа same и активационной функцией relu:

const model = tf.sequential({    layers: [        tf.layers.conv2d({            inputShape: image.shape,            filters: 1,            kernelSize: 3,            padding: 'same',            activation: 'relu'        })    ]});

Данная модель в качестве входных параметров принимает тензор формой [NUM_SAMPLES, WIDTH, HEIGHT,CHANNEL], однако tf.browser.fromPixel возвращает тензор размерностью [WIDTH, HEIGHT, CHANNEL], то поэтому нам надо модифицировать форму тензора и добавить еще одну ось там где располагаются образцы изображений (в нашем случае будет только один, так как одно изображение):

const input = image.reshape([1].concat(image.shape));

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

model.getLayer(null, 0).setWeights([    tf.tensor([         1,  1,  1,         0,  0,  0,        -1, -1, -1    ], [3, 3, 1, 1]),    tf.tensor([0])]);

Далее пропустим исходное изображение через модель, а также нормализуем выходной тензор таким образом, чтобы все значения были в промежутке 0-255, а также уберем ось NUM_SAMPLES:

const output = model.predict(input);const max = output.max().arraySync();const min = output.min().arraySync();const outputImage = output.reshape(image.shape)    .sub(min)    .div(max - min)    .mul(255)    .cast('int32');

Чтобы отобразить тензор на плоскости canvas, воспользуемся функцией tf.browser.toPixels:

tf.browser.toPixels(outputImage, document.getElementById('output-image-01'));

Тут приведен результат работы с применением двух разных фильтров:


2. Подвыборочный слой (pooling layer)

Обычно для обработки изображения, последовательно подключается несколько сверточных слоев (обычно это десять или даже сотня слоев), при этом каждый сверточный слой увеличивает размерность выходного изображения, что в свою очередь приводит к увеличению времени обучения такой сети. Для того, чтобы его снизить, после сверточных слоев применяют подвыборочный слой (pooling layer, subsample layer), которые уменьшают размерность выходного изображения. Обычно применяется MaxPooling операция.

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

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

Рисунок 10 Преобразование в подвыброчном слоеРисунок 10 Преобразование в подвыброчном слое

Как видим, при исходном изображении размерностью 4x4, подвыборочный слой с размерностью фильтра 2x2 и размерностью шага (stride) по умолчанию, равному размерностью фильтра 2x2, выходное изображение имеет размерность в два раза меньше входного.

Также покажем наглядно, что этот слой сглаживает пространственные смещения (рисунок 11) во входном слое. Обратите внимание, что на втором рисунке изображение смещено на один пиксел влево относительно первого изображения, тем не менее выходные изображения для обоих случаев после MaxPooling слоев идентичные. Это еще называют пространственной инверсией (translation invariance). На третьем рисунке, выходной слой уже не идентичен первым двум, но тем не менее пространственная инверсия составляет 50%. Тут в примере рассматривались смещения вдоль вертикальной, горизонтальных осях, но MaxPooling также толерантен к небольшим вращением изображениям также.

Рисунок 11 Сглаживание пространственных смещений после MaxPooling слояРисунок 11 Сглаживание пространственных смещений после MaxPooling слоя

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

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

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

АПИ TensorFlow.js для подвыборочного слоя (pooling layer)

В зависимости от слоя вы можете выбрать или tf.layers.maxPooling2d или tf.layers.averagePooling2d. Оба метода имеют одинаковую сигнатуру и принимают один аргумент объект параметров, среди которых важны к рассмотрению следующие:

- poolSize number | number[] размерность фильтра, если задана number, то размерность фильтра принимает квадратную форму, если задана массивом то высота и ширина могут отличаться

- strides number | number[] - шаг продвижения, не обязательный параметр и по умолчанию имеет туже размерность, что и заданный poolSize.

- padding same, valid настройка нулевого отступа, по умолчанию valid

Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru