Было бы здорово, если бы, когда вы идете в зоомагазин, вы знали, что получаете лучший корм для кошек для своего пушистого друга?
В этой статье мы воспользуемся облачными функциями Firebase для размещения модели TensorFlow.js и откроем ее для прогнозов с помощью вызовов API из Android ReactNativeПриложение — все по цене $0.
Начните с Firebase
Зарегистрируйте бесплатную учетную запись Firebase здесь: https://console.firebase.google.com/. Вам нужно будет создать проект, запомните его название.
На вашем компьютере установите firebase глобально:
npm install -g firebase-tools
Войдите в созданный вами аккаунт:
firebase login
Инициализируйте свою firebase, выбрав существующий созданный вами проект с возможностями облачных функций и эмулятором. Используйте следующую команду:
firebase init
Вы будете следовать инструкциям и активировать службы, упомянутые выше. В конце всего процесса у вас будет файл firebase.json, похожий на этот:
{ "database": { "rules": "database.rules.json" }, "functions": [ { "source": "functions", "codebase": "default", "ignore": [ "node_modules", ".git", "firebase-debug.log", "firebase-debug.*.log" ] } ], "emulators": { "functions": { "port": 5001 }, "ui": { "enabled": true }, "singleProjectMode": true } }
Создайте облачную функцию NodeJs
Инструмент firebase cli должен был создать папку functions, перейдите в эту папку и отредактируйте index.js, указав приведенные ниже сведения, чтобы создать сценарий входа:
const { onRequest } = require("firebase-functions/v2/https"); const logger = require("firebase-functions/logger"); const admin = require('firebase-admin'); admin.initializeApp(); const database = admin.database(); let GLOBAL_COUNT = 0; exports.helloWorld = onRequest(async (request, response) => { logger.info("Hello logs!", { structuredData: true }); // Save telemetry to Firebase Realtime Database await database.ref('telemetry').push({ msg: `Hello #${GLOBAL_COUNT++}`, timestamp: Date.now(), }); response.send("Hello from Firebase!"); });
установить все импортированные библиотеки:
npm install express firebase-admin
Эмулятор для проверки функции
Эмуляторы — это лучший способ попробовать сервис и API, не привязываясь к ценам Firebase, и чтобы проверить наш код перед переходом в облако, мы должны протестировать его на эмуляторе:
firebase emulators:start
он напечатает URL-адрес пользовательского интерфейса эмулятора, который вы можете использовать для перехода к:
Перейдите на вкладку функций и получите доступ к указанному URL-адресу. Вы должны увидеть вывод «hello world», и журналы должны начать отображаться в пользовательском интерфейсе:
После того, как все это проверено, давайте развернем на фактическую службу Firebase.
Привет Firebase
Чтобы развернуть наш код, мы вводим следующие команды в командной строке:
firebase deploy
Не беспокойтесь, если вас попросят перейти на тарифный план blaze (в основном из-за облачных функций), с вас ничего не будет взиматься за то, что мы сделаем в этой статье.
Если развертывание прошло успешно, вы должны увидеть это в своем приглашении:
Облачная функция должна быть видна из firebase:
Если вы свернете или используете Postman для показа этого URL-адреса в firebase, вы получите hello world:
Покажите нам несколько моделей!
Настало время использовать науку о данных и дать нашим питомцам преимущество при покупках.
Создадим папку, где будем обрабатывать данные и тестировать модель, мы это делаем для проверки нашего режима перед встраиванием в облачную функцию. CD в папку и запустите npm init
для инициализации простой установки пакета.
Отсюда установите все необходимые зависимости TensorFlowJS:
npm install @tensorflow/tfjs @tensorflow/tfjs-node nodeplotlib
В этом эксперименте у нас нет наборов данных, поэтому давайте их синтезируем. Нам нужны данные, описывающие свойства корма для кошек, и мы будем использовать их, чтобы найти лучшую цену для покупки.
Мы будем генерировать синтетические данные с помощью кода ниже:
/** * Tensorflow JS Analysis and Model Building. */ import * as tf from '@tensorflow/tfjs-node' import { plot } from 'nodeplotlib'; import Plot from 'nodeplotlib'; const { tidy, tensor2d } = tf; // Constants const BRANDS = ['Whiskers', 'Royal Feline', 'Meowarf', 'Unbranded']; const STORES = ['Fresh Pet', 'Expensive Cats', 'Overpriced Pets', 'Jungle of Money', 'Mom & Pop Petshop']; const MAX_DS_X = 1000; const EPOCHS = 30; /** * Generates random cat food data, either as normal or uniform data. * * @param numRows The size of the dataset in X * @returns 2darray of features. */ function generateData(numRows, wieghtRangeGrams = { min: 1000.0, max: 10000.0 }, brands = BRANDS, stores = STORES) { const brandIndices = tf.randomUniform([numRows], 0, brands.length, 'int32'); const brandLabels = brandIndices.arraySync().map(index => brands[index]); const locationIndices = tf.randomUniform([numRows], 0, stores.length, 'int32'); const locationLabels = locationIndices.arraySync().map(index => stores[index]); const bestBeforeDates = tf.randomUniform([numRows], 0, 365 * 5, 'int32'); const baseDate = new Date(); const bestBeforeDatesFormatted = bestBeforeDates.arraySync().map(days => { const date = new Date(baseDate); date.setDate(baseDate.getDate() + days); return date.toISOString().split('T')[0]; }); // Generate price values based on weights (with minor variance) const weights = tf.randomUniform([numRows], wieghtRangeGrams.min, wieghtRangeGrams.max, 'float32'); const pricesTemp = weights.div(120); const priceMean = tf.mean(pricesTemp).arraySync(); // Mean weight const priceStd = tf.moments(pricesTemp).variance.sqrt().arraySync(); const priceNoise = tf.randomNormal([numRows], priceMean, priceStd, 'float32'); let prices = tf.tensor1d(pricesTemp.add(priceMean).add(priceNoise).arraySync()); // Apply logic and transform each number prices = tf.tensor1d(prices.dataSync().map((value, index) => { const brandLabel = brandLabels[index]; let newPrice = value; switch (brandLabel) { case 'Unbranded': newPrice *= 0.82; break; case 'Royal Feline': newPrice *= 1.12; newPrice += 10; break; case 'Whiskers and Paws': newPrice *= 1.45; newPrice += 25; break; case 'Meowarf': newPrice *= 1.60; newPrice += 50; break; default: throw new Error(brandLabel); } return newPrice; })); const data = { weight: weights.arraySync(), brand: brandLabels, storeLocation: locationLabels, bestBeforeDate: bestBeforeDatesFormatted, priceUSD: prices.arraySync(), }; return data; }; ... console.log('Generating Synth Data'); const catFoodDataset = await generateData(MAX_DS_X);
Используя функцию нормального и равномерного распределения tensorflow, мы добавляем к нашим данным случайность, но нам нужен элемент корреляции между функциями, поэтому мы можем попытаться привязать цену к весу.
После того, как данные созданы, мы можем выполнить некоторые базовые EDA на основе javascript:
/** * Does some EDA on the given data. * * @param {*} { * weight: aray of floats, * brand: array of label strings, * storeLocation: array of label strings, * bestBeforeDate: array of iso dates, * priceUSD: aray of floats, * }; */ function dataEDA(data) { function _countUniqueLabels(labels) { return labels.reduce((counts, label) => { counts[label] = (counts[label] || 0) + 1; return counts; }, {}); } const { weight, brand, storeLocation, bestBeforeDate, priceUSD } = data; // Summary statistics const weightMean = tf.mean(weight); const weightStd = tf.moments(weight).variance.sqrt().arraySync(); const priceMean = tf.mean(priceUSD); const priceStd = tf.moments(priceUSD).variance.sqrt().arraySync(); console.log('Weight Summary:'); console.log(`Mean: ${weightMean.dataSync()[0].toFixed(2)}`); console.log(`Standard Deviation: ${weightStd}`); console.log('\nPrice Summary:'); console.log(`Mean: ${priceMean.dataSync()[0].toFixed(2)}`); console.log(`Standard Deviation: ${priceStd}`); // Histogram of weights const weightData = [{ x: weight, type: 'histogram' }]; const weightLayout = { title: 'Weight Distribution' }; plot(weightData, weightLayout); // Scatter plot of weight vs. price const scatterData = [ { x: weight, y: priceUSD, mode: 'markers', type: 'scatter' }, ]; const scatterLayout = { title: 'Weight vs. Price', xaxis: { title: 'Weight' }, yaxis: { title: 'Price' } }; plot(scatterData, scatterLayout); // Box plot of price const priceData = [{ y: priceUSD, type: 'box' }]; const priceLayout = { title: 'Price Distribution' }; plot(priceData, priceLayout); // Bar chart of a categorical feature const brandCounts = _countUniqueLabels(brand); const locCounts = _countUniqueLabels(storeLocation); const brandLabels = Object.keys(brandCounts); const locLabels = Object.keys(locCounts); const brandData = brandLabels.map(label => brandCounts[label]); const locData = locLabels.map(label => locCounts[label]); const brandBar = [{ x: brandLabels, y: brandData, type: 'bar' }]; const locBar = [{ x: locLabels, y: locData, type: 'bar' }]; const brandLayout = { title: 'Brand Distribution' }; const locLayout = { title: 'Location Distribution' }; plot(locBar, brandLayout); plot(brandBar, locLayout); // Line chart of price over time (Best before date) const priceOverTime = bestBeforeDate.map((date, index) => ({ x: date, y: priceUSD[index] })); priceOverTime.sort((a, b) => a.x - b.x); // Sort by date in ascending order const lineData = [{ x: priceOverTime.map(entry => entry.x), y: priceOverTime.map(entry => entry.y), type: 'scatter' }]; const lineLayout = { title: 'Price Over Time', xaxis: { type: 'date' }, yaxis: { title: 'Price' } }; plot(lineData, lineLayout); } ... await dataEDA(catFoodDataset); // For EDA only.
Эта библиотека nodeplotlib была создана для запуска сервера и визуализации данных, как если бы мы были на ноутбуке:
На приведенных выше графиках срок годности и местоположение магазина не имеют значения и должны быть исключены из функций. Цена, бренд и вес имеют корреляцию.
Создадим тренировочные сплиты:
/** * Cleans, nromalizes and drops irrelavant data. Then splits the data into train, validate, test sets. * * @param {*} data * @param {*} trainRatio * @param {*} testRatio * @param {*} valRatio * @returns {Object} of: { * trainData: {Tensor}, * testData: {Tensor}, * validationData: {Tensor} * } */ function cleanTrainSpitData(data, trainRatio = 0.7, testRatio = 0.1, valRatio = 0.2) { /** * local function to noramlize a range, will save the mins and maxs to a global cache to be used in a prediction. * * @see MINIMUMS * @returns {Array[*]} The normalized range. */ function _normalizeFeature(feature, featureName, metaData = DATASETS_METADATA) { const min = tf.min(feature); const max = tf.max(feature); const normalizedFeature = tf.div(tf.sub(feature, min), tf.sub(max, min)); // We will need to normalize input data with the same constants. metaData[featureName] = { min: min, max: max }; return normalizedFeature; } // Remove irrelevant features (date in this case) and NaNs const cleanedAndNormalizedData = { weight: [], brandOHE: [], storeOHE: [], priceUSD: [] }; for (let i = 0; i < data.weight.length; i++) { // Handle missing values if needed if (!isNaN(data.weight[i]) && !isNaN(data.priceUSD[i]) && (data.brand[i])) { cleanedAndNormalizedData.weight.push(data.weight[i]); cleanedAndNormalizedData.brandOHE.push(data.brand[i]); cleanedAndNormalizedData.priceUSD.push(data.priceUSD[i]); } } // Normalize the Data cleanedAndNormalizedData.weight = _normalizeFeature(cleanedAndNormalizedData.weight, 'weight'); cleanedAndNormalizedData.brandOHE = oneHotEncode(cleanedAndNormalizedData.brandOHE); cleanedAndNormalizedData.priceUSD = _normalizeFeature(cleanedAndNormalizedData.priceUSD, 'priceUSD'); const { weight, brandOHE, storeOHE, priceUSD } = cleanedAndNormalizedData; const totalSize = weight.shape[0]; const trainIndex = Math.floor(trainRatio * totalSize); const valSize = Math.floor(valRatio * totalSize); const testIndex = trainIndex + valSize; const trainData = { weight: weight.slice([0], [trainIndex]), brandOHE: brandOHE.slice([0], [trainIndex]), priceUSD: priceUSD.slice([0], [trainIndex]) }; const validationData = { weight: weight.slice([trainIndex], [valSize]), brandOHE: brandOHE.slice([trainIndex], [valSize]), priceUSD: priceUSD.slice([trainIndex], [valSize]) }; const testData = { weight: weight.slice([testIndex]), brandOHE: brandOHE.slice([testIndex]), priceUSD: priceUSD.slice([testIndex]) }; return { trainData: trainData, testData: testData, validationData: validationData }; } ... console.log('Clean and Split Data'); const datasets = await cleanTrainSpitData(catFoodDataset);
И построить модель:
/** * * @param {*} trainData * @param {*} validationData * @param {*} testData * @param {*} numEpochs */ async function buildLinearRegressionModel(trainData, validationData, testData, epochs) { const { weight, brandOHE, storeOHE, priceUSD } = trainData; const trainX = tf.tensor2d( tf.concat([ tf.tensor2d(weight.arraySync(), [weight.arraySync().length, 1]), tf.tensor2d(brandOHE.arraySync())], 1) .arraySync()); const trainY = tf.tensor1d(priceUSD.arraySync()); console.log('trainX shape:', trainX.shape); console.log('trainY shape:', trainY.shape); const model = tf.sequential(); model.add(tf.layers.dense({ units: trainX.shape[0], activation: 'sigmoid', inputShape: [trainX.shape[1]] })); model.add(tf.layers.dense({ units: trainX.shape[0] / 2, activation: 'sigmoid' })); model.add(tf.layers.dense({ units: 1, activation: 'linear' })); model.compile({ optimizer: 'adam', loss: 'meanSquaredError', metrics: ['accuracy'] }); const history = await model.fit(trainX, trainY, { validationData: validationData, epochs: epochs }); console.log("Model trained and fitted!") const { weight: testWeight, brandOHE: testBrandOHE, storeOHE: testStoreOHE, priceUSD: testPriceUSD } = testData; const testX = tf.tensor2d( tf.concat([ tf.tensor2d(testWeight.arraySync(), [testWeight.arraySync().length, 1]), tf.tensor2d(testBrandOHE.arraySync())], 1) .arraySync()); const testY = tf.tensor1d(testPriceUSD.arraySync()); console.log('testX shape:', testX.shape); console.log('testY shape:', testY.shape); const testPredictions = await model.predict(testX); return { model: model, predictions: testPredictions, trueValues: testY, history: history.history }; } ... console.log('Build Model'); const modelMetaData = await buildLinearRegressionModel(datasets.trainData, datasets.validationData, datasets.trainData, EPOCHS);
Завершение с оценкой:
/** * * @param {*} model * @param {*} testData */ async function modelMetrics(modelMetaData) { const accuracy = tf.metrics.binaryAccuracy(modelMetaData.trueValues, modelMetaData.predictions); const error = tf.metrics.meanAbsoluteError(modelMetaData.trueValues, modelMetaData.predictions); console.log(`Accuracy: ${accuracy.arraySync()[accuracy.arraySync().length - 1] * 100}%`); console.log(`Error: ${error.arraySync()[error.arraySync().length - 1] * 100}%`); console.log(`Loss: ${[modelMetaData.history.loss.length - 1]}%`); } ... console.log('Get Model Metrics'); await modelMetrics(modelMetaData, datasets.trainData);
Что дает нам следующие результаты:
Ой! не самые лучшие результаты. Тем не менее, мы обучали эту модель на нереалистичных данных. Мусор-в-мусор-выход.
Разогрейте этот API
Когда у вас есть модель на бессерверной установке, всегда полезно прогреть ее, чтобы кэшировать веса слоев.
Мы сделаем это с помощью функции loadModel:
/** * Loads meta data and model. * * Once loaded, warm up model with sample prediciton. */ function loadModel() { fs.readFile(`${FUNCTION_MODEL_PATH}/meta.json`, (err, data) => { if (err) throw err; logger.info(`Model metadata loaded ${data}`); DATASETS_METADATA = JSON.parse(data); const brand = oneHotEncode([BRANDS[1]], BRANDS, 'brand'); const wieghtInGrams = tf.tensor1d([5000]); const wieght = normalizeFeature(wieghtInGrams, 'weight'); tf.loadLayersModel(tfn.io.fileSystem(`${FUNCTION_MODEL_PATH}/model.json`)) .then((loadedModel) => { logger.info(`Model loaded ${loadedModel}, predicting sample: `); const x = tf.tensor2d( tf.concat([ tf.tensor2d(wieght.arraySync(), [wieght.arraySync().length, 1]), tf.tensor2d(brand.arraySync())], 1) .arraySync()); MODEL = loadedModel; return MODEL.predict(x); }).then((prediction) => { logger.info(`Predicted: '$${prediction}' for a brand: '${BRANDS[1]}' and weight: '${wieghtInGrams}g'`); }); }); } loadModel();
Мы не должны забыть взять с собой служебные функции, используемые в тестовом скрипте nodeJs для нормализации данных и выполнения одного oneHotEncoding, вместе со всеми метаданными из обученной модели: данные OHE и минимальные/максимальные веса.
В CLI введите emulators:run
и перейдите к URL-адресу функции (должен быть что-то вроде: http://127.0.0.1:5001/cloudfunctions-f2309/us-central1/catFoodPredictor).
Предполагая, что эмулятор в порту 4000, если вы заходите на http://127.0.0.1:4000/logs, вы должны увидеть прогноз прогрева:
Теперь мы обслуживаем модель с помощью API:
/** * POST only, predicts the price of the catfood item. */ exports.catFoodPredictor = onRequest(async (req, res) => { if (req.method !== 'POST') { return res.status(400).json({ error: 'Invalid request method. Only POST requests are allowed.' }); } const data = req.body; logger.info(`Received this: ${JSON.stringify(data)}`); await database.ref('telemetry').push({ data: JSON.stringify(data), timestamp: Date.now(), }); logger.info(`Received this: ${data.brand} and ${data.weight}`); const brand = oneHotEncode([data.brand], BRANDS, 'brand'); const weightInGrams = tf.tensor1d([data.weight]); const weight = normalizeFeature(weightInGrams, 'weight'); const x = tf.tensor2d( tf.concat([ tf.tensor2d(weight.arraySync(), [weight.arraySync().length, 1]), tf.tensor2d(brand.arraySync())], 1) .arraySync()); try { const prediciton = MODEL.predict(x).arraySync()[0]; res.status(200).json({ prediciton: prediciton }); logger.info(`Predicted this: ${JSON.stringify(prediciton)}`); } catch (err) { console.error('Error adding data:', error); res.status(500).json({ error: 'Something went wrong. Please try again later.' }); } });
Используя POSTMAN, протестируйте эмулируемую функцию, и вы должны увидеть следующий результат:
Наконец-то мы можем запустить Firebase! Разверните функции и базу данных аналитики, которую мы настроили, с помощью команды:
firebase deploy
Если мы перейдем к нашей панели инструментов Google Cloud и найдем вкладку журналов, мы увидим, что функция загружена, а модель прогрета:
Теперь последний и настоящий тест почтальона:
Предсказания в кармане
После успешного развертывания модели в firebase нам нужна портативность, чтобы мы могли использовать эту информацию в зоомагазине.
Здесь мы создадим приложение React Native. Мы рекомендуем прочитать нашу предыдущую статью о том, как создавать приложения для Android здесь, в ней описаны различные шаги по установке SDK устройств, настройке Android Studio и повторной регистрации виртуальных устройств, на которых мы можем работать.
Инициализируйте проект:
npx react-native@latest init fairCatApp
CD в только что созданную папку и протестируйте приложение с помощью npm run android
(или npm start
и выберите Android в Metro), чтобы увидеть страницу приветствия на виртуальном устройстве.
Обратите внимание, что YARN упоминается в другой литературе или в файлах проекта — пряжа похожа на npm.
Установите дополнительные библиотеки для форм ввода и POST-запроса: npm install axios react-native-dropdown-picker
.
В приложении мы создадим простую форму с выбором брендов кормов для кошек и числовым счетчиком их веса в граммах. Замените страницу приветствия в App.tsx следующим кодом:
import React, { useState, FC } from 'react'; import { View, StyleSheet, TextInput, Text, Button, Image, Alert, } from 'react-native'; import axios from 'axios'; import DropDownPicker from 'react-native-dropdown-picker'; const App: FC = () => { const DEFAULT_WEIGHT = DEFAULT_WEIGHT; const BRANDS: Array<Object> = [ { label: 'Unbranded', value: 'Unbranded' }, { label: 'Whiskers and Paws', value: 'Whiskers and Paws' }, { label: 'Royal Feline', value: 'Royal Feline' }, { label: 'Meowarf', value: 'Meowarf' }, ]; const [weight, setWeight] = useState('0'); const [open, setOpen] = useState(false); const [brand, setBrand] = useState(BRANDS[0].value); const [brands, setBrands] = useState(BRANDS); const [prediction, setPrediction] = useState([]); /** * Submit Weight and Brand to firebase for a prediction. */ const handleSubmit = () => { const data = { brand: brand, weight: weight, }; axios .post( 'https://catfoodpredictor-2526dyxuva-uc.a.run.app/catFoodPredictor', data, { headers: { 'Access-Control-Allow-Origin': '*', 'Content-Type': 'application/json', }, }, ) .then(response => response.data) .then(result => { setPrediction(result); Alert.alert( `Predicted: $${Number.parseFloat(result?.prediciton).toFixed(2)}`, ); }) .catch(err => { Alert.alert(`Error: ${err}`); }); }; return ( <View style={styles.container}> <Image style={styles.image} source={require('./img/freeCatLogo.jpg')} /> <Text style={styles.title}>fair Cat!</Text> <View style={styles.container}> <Text>Weight in Grams: </Text> <TextInput style={styles.input} label="Weight in Grams" value={weight} onChangeText={setWeight} keyboardType="numeric" inputMode="numeric" /> <Text>Select Brand from dropdown: </Text> <DropDownPicker open={open} value={brand} items={brands} setOpen={setOpen} setValue={setBrand} setItems={setBrands} /> {prediction?.length > 0 && ( <Alert title="Prediction" message={prediction.join(', ')} onPress={() => { setPrediction([]); }} /> )} </View> <View style={styles.button}> <Text> Selected Brand: [{brand}] and Quantity: [{weight}]g. </Text> <View style={{ padding: 10 }} /> <Text>Submit for Prediction: </Text> <Button title="Predict Price" onPress={handleSubmit} /> </View> </View> ); }; const styles = StyleSheet.create({ container: { flex: 1, padding: 16, width: 350, alignSelf: 'center', }, button: { alignSelf: 'center', }, input: { width: 320, borderColor: '#000', borderWidth: 1, borderRadius: 10, alignSelf: 'center', backgroundColor: '#fff', color: '#000', }, image: { width: 100, height: 100, alignSelf: 'center', marginTop: 16, }, title: { fontSize: 20, textAlign: 'center', marginTop: 8, }, }); export default App;
Компонент TextInput предназначен для веса, который будет преобразован в число от 500 до 100 000 граммов, а настраиваемый элемент DropDownPicker — для раскрывающегося списка брендов.
Axios используется для доставки почтового запроса в функцию firebase, и результат будет отображаться в приложении в виде модального диалогового окна с Alert.
Некоторые проблемы, с которыми вы можете столкнуться, будут: утверждение ваших лицензий SDK, запуск Metro, чтобы позволить Android Studio подключить приложение React к вашему устройству, и если вы пытаетесь использовать приложение из виртуальных устройств, библиотека axios может не выполнять никаких запросов.
Если все пойдет хорошо, вот что вы увидите:
Заключение
Мы полностью изучили стек мобильных приложений на базе науки о данных (только для Android) с помощью tensorflowJS.
Мы построили простую нейронную сеть для прогнозирования непрерывного значения, которое является ценой. Это было протестировано на синтетических данных и обучено с помощью простого скрипта nodejs перед развертыванием в нашей базе данных.
Затем мы загрузили облегченное приложение ReactNative, которое вызывает нашу функцию базы данных — и для некоторых из вас — установили приложение на свой компьютер. настоящее устройство.
Теперь у вас есть приложение, которое дает вам справедливую цену на кошачий корм, который вы покупаете. Ваш кошелек и ваша кошка будут намного счастливее в этом году.
Рекомендации
- https://firebase.google.com/docs/functions
- https://www.tensorflow.org/js
- https://medium.com/call-for-atlas/vuejs-and-capacitor-portable-mobile-apps-made-easy-d14bce8b53a
- https://reactnative.dev/docs/environment-setup?guide=native
Гитхаб
Статья и исходный код здесь доступны на Github
СМИ
Все используемые медиафайлы (в виде кода или изображений) либо принадлежат исключительно мне, либо приобретены по лицензии, либо являются частью общественного достояния и предоставлены для использования по лицензии Creative Commons.
Лицензирование и использование CC
Эта работа находится под лицензией Creative Commons Attribution-NonCommercial 4.0 International License.
Сделано с ❤ автором Адамом