obniz-parts-kits
Version:
sdk for obniz ai/iot kits
852 lines (729 loc) • 21 kB
JavaScript
class ObnizAIHelper {
constructor() {
this.tracker = new clm.tracker();
this.tracker.init(pModel);
this._emotionThreshold = 0.5;
this._posenetThreshold = 0.6;
this._posnetPos = [];
this._emotion_classifier = new emotionClassifier();
this._emotion_classifier.init(emotionModel);
this._posenet = null;
/* tfjs */
this.tfModel = undefined;
this.video = undefined;
this.tfclassify = {
time: null,
name: 'unknown',
};
this.closestFace = {
time: null,
found: false,
x: 0,
y: 0,
distance: 0,
};
this.detectedWhiteLine = {
center_x: 0,
};
this.addWeatherList();
let AudioContext = window.AudioContext || window.webkitAudioContext; //クロスブラウザ対応
this.audioCtx = new AudioContext();
this.accel = {
x: null,
y: null,
z: null,
logs: [],
};
this.gyro = {
alpha: null,
beta: null,
gamma: null,
logs: [],
};
this.orientation = {
absolute: null,
alpha: null,
beta: null,
gamma: null,
};
this.emotions = {
angly: 0,
sad: 0,
disgusted: 0,
fear: 0,
surprised: 0,
happy: 0,
};
}
/* Cam Management */
async startCamWait() {
try {
this._prepareDOM();
await this._loadModel();
this._posenet = await posenet.load();
await this._startVideo();
this.cap = new cv.VideoCapture(this.video);
this.tracker.start(this.video);
} catch (e) {
console.error(e);
let div = document.createElement('div');
div.innerHTML = `Cannot start camera on your device.`;
const viodeDOM = div.firstChild;
let output = document.getElementById('OBNIZ_OUTPUT');
output.appendChild(viodeDOM);
}
}
_prepareDOM() {
let div = document.createElement('div');
div.innerHTML = `<video id="video_forairobotkit" width="320px" height="240px" autoplay playsinline style="background-color: #888"></video>`;
const viodeDOM = div.firstChild;
let output = document.getElementById('OBNIZ_OUTPUT');
output.appendChild(viodeDOM);
this.video = document.getElementById('video_forairobotkit');
}
async _loadCascadeModel() {
const path = 'haarcascade_frontalface_default.xml';
const url =
'https://unpkg.com/obniz-parts-kits@0.2.0/ai/opencv3.4/haarcascade_frontalface_default.xml';
const response = await fetch(url);
const buf = await response.arrayBuffer();
cv.FS_createDataFile('/', path, new Uint8Array(buf), true, false, false);
}
async _loadModel() {
/* opencv */
await this._loadCascadeModel();
this._cv_classifier = new cv.CascadeClassifier();
const loaded = this._cv_classifier.load(
'haarcascade_frontalface_default.xml'
);
if (!loaded) {
throw new Error('oepncv cascade not loaded');
}
/* tf */
this._mobileNet = await mobilenet.load();
}
_startVideo() {
const video = this.video;
if (!navigator.mediaDevices) {
navigator.mediaDevices =
navigator.mozGetUserMedia || navigator.webkitGetUserMedia
? {
getUserMedia: function (c) {
return new Promise(function (y, n) {
(
navigator.mozGetUserMedia || navigator.webkitGetUserMedia
).call(navigator, c, y, n);
});
},
}
: null;
}
return new Promise((resolve, reject) => {
navigator.mediaDevices
.getUserMedia({
video: { facingMode: 'user' },
audio: false,
})
.then((stream) => {
video.srcObject = stream;
video.onloadedmetadata = (e) => {
video.play();
resolve();
};
});
});
}
/* opencv */
_detectFace() {
const video = this.video;
if (this.closestFace.time !== this.video.currentTime) {
this.closestFace.time = this.video.currentTime;
if (!this.cap) {
return;
}
const cap = this.cap;
let src = new cv.Mat(video.height, video.width, cv.CV_8UC4);
let gray = new cv.Mat();
let faces = new cv.RectVector();
cap.read(src);
// detect faces.
cv.cvtColor(src, gray, cv.COLOR_RGBA2GRAY, 0);
let msize = new cv.Size(0, 0);
this._cv_classifier.detectMultiScale(
gray,
faces,
1.1,
3,
0,
msize,
msize
);
this.closestFace.found = false;
this.closestFace.distance = 101;
for (let i = 0; i < faces.size(); ++i) {
let face = faces.get(i);
const distance = ((video.width - face.width) / video.width) * 100;
if (distance < this.closestFace.distance) {
this.closestFace.found = true;
this.closestFace.x =
(((face.x + face.width / 2) / video.width) * 2 - 1) * 100;
this.closestFace.y =
(((face.y + face.height / 2) / video.height) * 2 - 1) * 100;
this.closestFace.distance = distance;
}
}
src.delete();
gray.delete();
faces.delete();
}
}
isAngry() {
return this.isEmotionDetected('angly');
}
isSad() {
return this.isEmotionDetected('sad');
}
isHappy() {
return this.isEmotionDetected('happy');
}
isDisgusted() {
return this.isEmotionDetected('disgusted');
}
isFear() {
return this.isEmotionDetected('fear');
}
isSurprised() {
return this.isEmotionDetected('surprised');
}
getEmotionValue(emotionType) {
this._detectEmotion();
return this.emotions[emotionType];
}
isEmotionDetected(emotionType) {
return this.getEmotionValue(emotionType) > this._emotionThreshold;
}
_detectEmotion() {
this._detectFace();
// let positions = this.tracker.getCurrentPosition();
let parameters = this.tracker.getCurrentParameters();
let emotion = this._emotion_classifier.meanPredict(parameters);
let emotions = {};
for (let i = 0; i < emotion.length; i++) {
emotions[emotion[i].emotion] = emotion[i].value;
}
this.emotions = emotions;
}
isFaceInside() {
this._detectFace();
this._detectEmotion();
return this.closestFace.found;
}
positionOfFace() {
//this._detectFace();
if (this.closestFace.found) {
return this.closestFace.x;
} else {
return 0;
}
}
distanceOfFace() {
//this._detectFace();
if (this.closestFace.found) {
return this.closestFace.distance;
} else {
return 101;
}
}
async _detectPoseWait() {
const video = this.video;
if (!this._posenet) {
return;
}
const imageScaleFactor = 0.2;
const outputStride = 16;
const flipHorizontal = true;
if (this._posenetDetectTime !== this.video.currentTime) {
this._posenetDetectTime = this.video.currentTime;
const pose = await this._posenet.estimateSinglePose(
video,
imageScaleFactor,
flipHorizontal,
outputStride
);
this._posnetPos = pose;
return pose;
}
}
/**
*
* @param name
* [
// "nose",
// "leftEye",
// "rightEye",
// "leftEar",
// "rightEar",
// "leftShoulder",
// "rightShoulder",
// "leftElbow",
// "rightElbow",
// "leftWrist",
// "rightWrist",
// "leftHip",
// "rightHip",
// "leftKnee",
// "rightKnee",
// "leftAnkle",
// "rightAnkle"
// ]
* @return {Promise<void>}
*/
async getPosetnetPosition(name) {
await this._detectPoseWait();
if (!this._posnetPos || !this._posnetPos.keypoints) {
return null;
}
for (const pose of this._posnetPos.keypoints) {
if (pose.part === name && pose.score >= this._posenetThreshold) {
return pose.position;
}
}
return null;
}
async isNormalPose() {
return (
!(await this.isRightHandsUpPose()) && !(await this.isLeftHandsUpPose())
);
}
async isBothHandsUpPose() {
return (
(await this.isRightHandsUpPose()) && (await this.isLeftHandsUpPose())
);
}
async isOneHandsUpPose() {
const left = await this.isLeftHandsUpPose();
const right = await this.isRightHandsUpPose();
if ((left && !right) || (!left && right)) return true;
return false;
}
async isRightHandsUpPose() {
let nosePos = await this.getPosetnetPosition('nose');
let rightWristPos = await this.getPosetnetPosition('rightWrist');
if (nosePos && rightWristPos) {
if (nosePos.y > rightWristPos.y) {
return true;
}
}
return false;
}
async isLeftHandsUpPose() {
let nosePos = await this.getPosetnetPosition('nose');
let leftWristPos = await this.getPosetnetPosition('leftWrist');
if (nosePos && leftWristPos) {
if (nosePos.y > leftWristPos.y) {
return true;
}
}
return false;
}
positionOfWhiteline() {
// reutn -100 to 100. notfound=0
const video = this.video;
if (this.closestFace.time !== this.video.currentTime) {
this.closestFace.time = this.video.currentTime;
const cap = this.cap;
let src = new cv.Mat(video.height, video.width, cv.CV_8UC4);
let gray = new cv.Mat();
cap.read(src);
cv.cvtColor(src, gray, cv.COLOR_RGBA2GRAY, 0);
let window = parseInt(video.width * 0.05);
// let boundary = [1, 1, 1];
let max_bright = 0;
let max_bright_x = 0;
const roi_h = parseInt(video.height * 0.7);
console.log('start');
for (let col = 0; col < video.width - window; col++) {
// let lastBoundary = 255;
let pixel = 0;
for (let w = 0; w < window; w++) {
pixel += gray.ucharPtr(roi_h, col + w)[0];
}
pixel /= window;
if (max_bright < pixel) {
max_bright = pixel;
max_bright_x = col;
}
}
let center_x = max_bright_x + parseInt(window / 2);
center_x = ((center_x / video.width) * 2 - 1) * 100;
this.detectedWhiteLine.center_x = parseInt(center_x);
gray.delete();
src.delete();
}
return this.detectedWhiteLine.center_x;
}
/* tensorflow */
async classify() {
if (this.tfclassify.time !== this.video.currentTime) {
// http://starpentagon.net/analytics/imagenet_ilsvrc2012_dataset/
const predictions = await this._mobileNet.classify(this.video);
for (let i = 0; i < predictions.length; i++) {
const name = predictions[i].className;
// const probability = predictions[i].probability;
this.tfclassify.time = this.video.currentTime;
this.tfclassify.name = name;
break;
}
}
return this.tfclassify.name;
}
/* speech */
async say(mes, rate, pitch) {
let ready = new Promise((resolve, reject) => {
$(() => {
resolve();
});
});
await ready; // for "Remove SpeechSynthesis.speak without user activation". https://www.chromestatus.com/feature/5687444770914304
let p = new Promise((resolve, reject) => {
const synth = window.speechSynthesis;
let message = new SpeechSynthesisUtterance(mes);
if (typeof rate === 'number') {
message.rate = rate;
}
if (typeof pitch === 'number') {
message.pitch = pitch;
}
message.onerror = (err) => {
reject(err);
};
synth.speak(message);
resolve();
});
let result = await p;
return result;
}
/* API related */
async getWeather(region) {
const key = '4aa1a96a50432353baf849e808c112e5';
const url = `https://api.openweathermap.org/data/2.5/forecast?q=${region}&APPID=${key}`;
let response = await fetch(url);
let json = await response.json();
if (
!json ||
!json.list ||
!json.list[0] ||
!json.list[0].weather ||
!json.list[0].weather[0]
) {
return 'unknown';
}
return json.list[0].weather[0].main.toLowerCase();
}
addWeatherList() {
this.sunny = ['clear (night)', 'clear', 'sunny'];
this.cloudy = [
'cloudy',
'mostly cloudy (night)',
'mostly cloudy (day)',
'partly cloudy (night)',
'partly cloudy (day)',
'mostly cloudy',
'partly cloudy',
'clouds',
];
this.rain = [
'mixed rain and snow',
'mixed rain and sleet',
'freezing drizzle',
'drizzle',
'freezing rain',
'showers',
'mixed rain and hail',
'scattered showers',
'atmosphere',
'rain',
'thunderstorm',
];
this.snow = [
'mixed snow and sleet',
'snow flurries',
'light snow showers',
'blowing snow',
'snow',
'heavy snow',
'scattered snow showers',
'snow showers',
];
}
playAudio(hz, ms) {
return new Promise((resolve) => {
//正弦波の音を作成
let osciillator = this.audioCtx.createOscillator();
//ヘルツ(周波数)指定
osciillator.frequency.value = hz;
//音の出力先
let audioDestination = this.audioCtx.destination;
//出力先のスピーカーに接続
osciillator.connect(audioDestination);
//音を出す
osciillator.start = osciillator.start || osciillator.noteOn; //クロスブラウザ対応
osciillator.start();
//音を0.5秒後にストップ
setTimeout(function () {
osciillator.stop();
resolve();
}, ms);
});
}
playMusic(url) {
return new Promise((resolve) => {
new Howl({
src: [url],
autoplay: true,
loop: false,
onend: resolve,
});
});
}
/* Accel */
async startMotionWait() {
try {
await this._confirmPrompt(
'This app request permission of device motions'
);
if (
DeviceMotionEvent &&
typeof DeviceMotionEvent.requestPermission === 'function'
) {
let permissionState = await DeviceMotionEvent.requestPermission();
if (permissionState === 'granted') {
// 許可を得られた場合、devicemotionをイベントリスナーに追加
window.addEventListener(
'devicemotion',
this.onDeviceMotion.bind(this)
);
} else {
// 許可を得られなかった場合の処理
throw new Error('Cannot get devicemotion permission');
}
permissionState = await DeviceOrientationEvent.requestPermission();
if (permissionState === 'granted') {
// 許可を得られた場合、deviceorientationをイベントリスナーに追加
window.addEventListener(
'deviceorientation',
this.onDeviceOrientation.bind(this)
);
} else {
throw new Error('Cannot get deviceorientation permission');
}
} else {
window.addEventListener('devicemotion', this.onDeviceMotion.bind(this));
window.addEventListener(
'deviceorientation',
this.onDeviceOrientation.bind(this)
);
}
} catch (e) {
console.error(e);
}
}
_confirmPrompt(title, text) {
if (title && !text) {
text = title;
title = undefined;
}
return new Promise((resolve, reject) => {
let html = '';
html += '<div class="modal fade" aria-hidden="true">';
html +=
' <div class="modal-dialog modal-dialog-centered" role="document">\n';
html += ' <div class="modal-content">\n';
if (title) {
html += ' <div class="modal-header">\n';
html += ' <h5 class="modal-title" id="exampleModalLongTitle">';
html += title;
html += ' </h5>\n';
html += ' </div>\n';
}
html += ' <div class="modal-body">\n';
html += text;
html += ' </div>\n';
html += ' <div class="modal-footer">\n';
html +=
' <button type="button" class="btn btn-primary">OK</button>\n';
html += ' </div>\n';
html += ' </div>\n';
html += ' </div>\n';
html += '</div>';
let div = document.createElement('div');
div.innerHTML = html;
div.querySelector('button').addEventListener('click', () => {
$(div.firstChild).modal('hide');
resolve();
});
document.body.appendChild(div);
$(div.firstChild).modal('show');
});
}
onDeviceMotion(e) {
// console.log("onDeviceMotion")
let now = new Date();
let accel = e.acceleration;
accel.time = now;
let gyro = e.rotationRate;
gyro.time = now;
this.accel.x = accel.x;
this.accel.y = accel.y;
this.accel.z = accel.z;
this.accel.logs.push(accel);
this.gyro.alpha = gyro.alpha;
this.gyro.beta = gyro.beta;
this.gyro.gamma = gyro.gamma;
this.gyro.logs.push(gyro);
this.deviceMotionLogFilter();
}
onDeviceOrientation(e) {
this.orientation = {
absolute: e.absolute,
alpha: e.alpha,
beta: e.beta,
gamma: e.gamma,
};
}
deviceMotionLogFilter() {
let now = new Date();
let time = 1000;
this.accel.logs = this.accel.logs.filter((elm) => {
return elm.time.getTime() + time > now.getTime();
});
this.gyro.logs = this.gyro.logs.filter((elm) => {
return elm.time.getTime() + time > now.getTime();
});
}
getAccelX() {
return this.accel.x;
}
getAccelY() {
return this.accel.y;
}
getAccelZ() {
return this.accel.z;
}
getGyroAlpha() {
return this.gyro.alpha;
}
getGyroBeta() {
return this.gyro.beta;
}
getGyroGannma() {
return this.gyro.gamma;
}
isShaked() {
let maxX = this.accel.logs.reduce((a, b) => (a.x > b.x ? a : b), {}).x;
let maxY = this.accel.logs.reduce((a, b) => (a.y > b.y ? a : b), {}).y;
let maxZ = this.accel.logs.reduce((a, b) => (a.z > b.z ? a : b), {}).z;
let minX = this.accel.logs.reduce((a, b) => (a.x < b.x ? a : b), {}).x;
let minY = this.accel.logs.reduce((a, b) => (a.y < b.y ? a : b), {}).y;
let minZ = this.accel.logs.reduce((a, b) => (a.z < b.z ? a : b), {}).z;
// console.log({maxX, minX})
if (maxX - minX > 6 || maxY - minY > 6 || maxZ - minZ > 6) {
return true;
}
return false;
}
isDeviceFaceDirection(dir) {
if (dir === 'sky') {
return (
Math.abs(this.orientation.beta) < 10 &&
Math.abs(this.orientation.gamma) < 10
);
} else if (dir === 'earth') {
return (
Math.abs(this.orientation.beta) > 170 &&
Math.abs(this.orientation.gamma) < 10
);
}
return false;
}
}
class TMModel {
/**
* @type {string}
*/
url ;
/**
* @type {null|tmImage.ClassificationModel}
*/
model = null;
/**
* @type {number | null}
*/
maxPredictions= null;
/**
*
* @type {number}
*/
lastPredictTime = 0;
/**
*
* @type {null|Object}
*/
lastPredictResult = null;
/**
* @param {string} url
*/
constructor(url) {
this.url = url;
this._init();
}
async _init() {
const modelURL = this.url + "model.json";
const metadataURL = this.url + "metadata.json";
// load the model and metadata
// Refer to tmImage.loadFromFiles() in the API to support files from a file picker
// or files from your local hard drive
// Note: the pose library adds "tmImage" object to your window (window.tmImage)
this.model = await tmImage.load(modelURL, metadataURL);
this.maxPredictions = this.model.getTotalClasses();
}
async waitForInit() {
for (let i= 0; i < 100; i++) {
if(this.model !== null){return;}
await new Promise(resolve => setTimeout(resolve, 100));
}
//10秒立ってもだめならNG
throw new Error("Teachable Machine Model load failed");
}
async predict(image) {
await this.waitForInit();
if(new Date().getTime() < this.lastPredictTime + 100){
return this.lastPredictResult;
}
const prediction = await this.model.predict(image);
this.lastPredictTime = new Date().getTime();
this.lastPredictResult = prediction;
return prediction;
}
async predictClassName(image) {
const predict = await this.predict(image);
const result = predict.reduce((a, b) => a.probability > b.probability ? a : b);
return result.className;
}
async predictProbability(image, className) {
const predict = await this.predict(image);
if(predict.map(a => a.className).indexOf(className) === -1){
throw new Error(`Cannot find class name. ${className} is not in ${predict.map(a => a.className).join(", ")}`);
}
const result = predict.find((a) => a.className === className);
if(!result){
return null;
}
return result.probability;
}
}
if (typeof module === 'object') {
module.exports = ObnizAIHelper;
} else {
_ai = new ObnizAIHelper();
}