JavaScript
iPhone
電子工作
OpenCV
おうちハック

スマホを最高級電子部品として電子工作に組み込む(カメラ編)

追記

スマホを最高級電子部品として電子工作に組み込む(加速度編)も書きました.
こちらもよろしくおねがいします

まず最初に:作ったもの

iPhoneのカメラで顔を検出して,サーボモータが顔の方向にファンを向ける というものです.

20180515_101959.GIF

作った背景

電子工作で使うセンサはたくさん種類がありますが,カメラやマイク,加速度など,ちょっと高度なセンサになると値段がそこそこするものでも,いまいち思ったような性能が出ない...ということがありました.

カメラで言えば,小さいモジュールが多いのですが,どうしても解像度や連写速度がイマイチです.それはそれで小さくできるし楽しいですが,せっかくなら解像度が高いカメラを使って電子工作したいものです.

そんなときに,「高機能なカメラといえば,手元にはiPhoneがあるじゃないか!」とひらめいてしまったので,手持ちのなかで一番高性能なカメラであるiPhoneを電子部品として,電子工作に組み込んでみました.

どうやるか

obniz という開発ボードを使います.
HTMLでプログラムをする開発ボードで,スマートフォンのカメラとの連携もHTML5経由で簡単にできました

スマートフォンを使っていますが,swiftもjavaも一切書かず,HTML/Javascriptのみで動いています

iphoneFan.png

材料

IMG_0009.JPG

ハードウェアの製作

ハードウェアは3ステップです

  1. obnizとサーボモーターをつなぐ
  2. USBファンにUSB↔ピンヘッダ変換基板をつなぎ,さらにそれをobnizにつなぐ
  3. obnizを電源につなぐ

obnizが1Aまで出力できるので,モータードライバを別に用意せずにモーターをマイコンに直挿しすることができてとても楽です
fan2.png

ソフトウェアの製作

ソフトウェアはハードウェアほど簡単ではなく,しっかりと書く必要があります.

  1. HTML5 MultiMediaをつかってカメラを取得する
  2. OpenCVにカメラデータを入れ,顔を検出する
  3. 顔位置のX座標を元にサーボモータを回す

1. HTML5 MultiMediaをつかってカメラを取得する

videoタグがあるので,それを使用します.
navigator.getUserMedia関数を使って,カメラの許可をユーザーに求め,許可されればコールバックでstreamがもらえますので,videoタグのプロパティに設定します.

これで,ボタンを押したらカメラが起動し,HTML上に表示されます.
表示されない場合は,HTTPで通信していないか確認して下さい,
セキュリティの関係上,HTTPSのサイトでしかカメラが取れないようです

<video id="videoInput" autoplay playsinline width=320 height=240>
<button id="startAndStop">Start</button>

<script>
  let videoInput = document.getElementById('videoInput');
  let startAndStop = document.getElementById('startAndStop');

  function successCallback(stream) {
    document.getElementById("videoInput").srcObject = stream;
    onVideoStarted();
  };

  function errorCallback(err) {
    console.error('mediaDevice.getUserMedia() error:', error);
  };

  startAndStop.addEventListener('click', () => {
    if (!streaming) {
      const medias = {
        audio: false, video: {
          facingMode: "user" 
        }
      };
      navigator.getUserMedia(medias, successCallback, errorCallback);
    } else {
      utils.stopCamera();
      onVideoStopped();
    }
  });

  function onVideoStarted() {
    startAndStop.innerText = 'Stop';

   // ...
  }

  function onVideoStopped() {
    startAndStop.innerText = 'Start';

   // ...
  }

</script>

2. OpenCVにカメラデータを入れ,顔を検出する

OpenCVのページにサンプルがあるので,ほぼそれを流用します.
haarcascade_frontalface_default.xmlが顔の情報が入っているデータで,コレを使うことで顔を認識しています.
出力はcanvasで,この顔が赤く枠で囲われた写真が表示されます

<canvas id="canvasOutput" width=320 height=240 style="-webkit-font-smoothing:none">

<script src="https://docs.opencv.org/3.4/opencv.js"></script>
<script src="https://webrtc.github.io/adapter/adapter-5.0.4.js" type="text/javascript"></script>
<script src="https://docs.opencv.org/3.4/utils.js" type="text/javascript"></script>

<script>

  let streaming = false;

  function onVideoStarted() {
    streaming = true;
    startAndStop.innerText = 'Stop';
    start();
  }

  function onVideoStopped() {
    streaming = false;
    canvasContext.clearRect(0, 0, canvasOutput.width, canvasOutput.height);
    startAndStop.innerText = 'Start';
  }

  let utils = new Utils('errorMessage');

  let faceCascadeFile = 'haarcascade_frontalface_default.xml';
  utils.createFileFromUrl(faceCascadeFile, 'https://raw.githubusercontent.com/opencv/opencv/master/data/haarcascades/haarcascade_frontalface_default.xml', () => {
    startAndStop.removeAttribute('disabled');
  });

  async function start() {
    let video = document.getElementById('videoInput');
    let src = new cv.Mat(video.height, video.width, cv.CV_8UC4);
    let dst = new cv.Mat(video.height, video.width, cv.CV_8UC4);
    let gray = new cv.Mat();
    let cap = new cv.VideoCapture(video);
    let faces = new cv.RectVector();
    let classifier = new cv.CascadeClassifier();


    let result = classifier.load("haarcascade_frontalface_default.xml");

    const FPS = 30;

    function processVideo() {
      try {
        if (!streaming) {
          // clean and stop.
          src.delete();
          dst.delete();
          gray.delete();
          faces.delete();
          classifier.delete();
          return;
        }
        let begin = Date.now();
        // start processing.
        cap.read(src);
        src.copyTo(dst);
        cv.cvtColor(dst, gray, cv.COLOR_RGBA2GRAY, 0);
        // detect faces.
        classifier.detectMultiScale(gray, faces, 1.1, 3, 0);
        // draw faces.
        for (let i = 0; i < faces.size(); ++i) {
          let face = faces.get(i);
          let point1 = new cv.Point(face.x, face.y);
          let point2 = new cv.Point(face.x + face.width, face.y + face.height);
          cv.rectangle(dst, point1, point2, [255, 0, 0, 255]);
        }
        cv.imshow('canvasOutput', dst);

        // schedule the next one.
        let delay = 1000 / FPS - (Date.now() - begin);
        setTimeout(processVideo, delay);
      } catch (err) {
        console.error(err);
      }
    };

    // schedule the first one.
    setTimeout(processVideo, 0);

  }

</script>

スクリーンショット 2018-05-23 16.31.42.png

3. 顔位置のX座標を元にサーボモータを回す

一番電子工作らしいところですね
ここのプログラムはそれほど長くありません

USBとサーボモーターをどこに繋いだか,サーボモーターの角度をどれ位にするか しか殆ど書いてないです.

new Obniz("17804573");とあるのが自分の手持ちのobnizとの接続部分で,シリアル番号を入れて接続します

<script src="https://unpkg.com/obniz@1.2.1/obniz.js"></script>

<script>
let obniz = new Obniz("17804573");
let servo;
obniz.onconnect = async () => {
  obniz.display.print("ready")
  var usb = obniz.wired("USB" , {gnd:11, vcc:8} );
  usb.on();

  servo = obniz.wired("ServoMotor", {signal:0,vcc:1, gnd:2});

}

if(/* 顔が検出できたとき */){
  servo.angle(xPos * 180 / 320);
}

</script>


1〜3をまとめると,こんなコードになります

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <title>Video Capture Example</title>
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <script src="https://docs.opencv.org/3.4/opencv.js"></script>
  <script src="https://obniz.io/js/jquery-3.2.1.min.js"></script>
  <script src="https://unpkg.com/obniz@1.2.1/obniz.js"></script>
  <style>
.refrect-lr{
  -webkit-transform: scaleX(-1);
  -o-transform: scaleX(-1);
  -moz-transform: scaleX(-1);
  transform: scaleX(-1);
  filter: FlipH;
  -ms-filter: "FlipH";
}
  </style>
</head>
<body>

<div id="obniz-debug"></div>

<div>
  <div class="control">
    <button id="startAndStop">Start</button>
  </div>
</div>
<p class="err" id="errorMessage"></p>
<div>
  <table cellpadding="0" cellspacing="0" width="0" border="0">
    <tr>
      <td>
        <video id="videoInput" autoplay playsinline width=320 height=240 class="refrect-lr"></video>
      </td>
      <td>
        <canvas id="canvasOutput" width=320 height=240 style="-webkit-font-smoothing:none"

                class="refrect-lr"></canvas>
      </td>
      <td></td>
      <td></td>
    </tr>
    <tr>
      <td>
        <div class="caption">videoInput</div>
      </td>
      <td>
        <div class="caption">canvasOutput</div>
      </td>
      <td></td>
      <td></td>
    </tr>
  </table>
</div>

<script src="https://webrtc.github.io/adapter/adapter-5.0.4.js" type="text/javascript"></script>
<script src="https://docs.opencv.org/3.4/utils.js" type="text/javascript"></script>
<script type="text/javascript">

  var servo;

  obniz = new Obniz("17804573");

  obniz.onconnect = async () => {
    obniz.display.print("ready")
    var usb = obniz.wired("USB" , {gnd:11, vcc:8} );
    usb.on();

    servo = obniz.wired("ServoMotor", {signal:0,vcc:1, gnd:2});

  }

  let utils = new Utils('errorMessage');

  let faceCascadeFile = 'haarcascade_frontalface_default.xml';
  utils.createFileFromUrl(faceCascadeFile, 'https://raw.githubusercontent.com/opencv/opencv/master/data/haarcascades/haarcascade_frontalface_default.xml', () => {
    startAndStop.removeAttribute('disabled');
  });


  let streaming = false;
  let videoInput = document.getElementById('videoInput');
  let startAndStop = document.getElementById('startAndStop');
  let canvasOutput = document.getElementById('canvasOutput');
  let canvasContext = canvasOutput.getContext('2d');


  function successCallback(stream) {
    document.getElementById("videoInput").srcObject = stream;
    onVideoStarted();
  };

  function errorCallback(err) {
    console.error('mediaDevice.getUserMedia() error:', error);
  };

  startAndStop.addEventListener('click', () => {


    if (!streaming) {
      utils.clearError();

      const medias = {
        audio: false, video: {
          facingMode: "user" 
        }
      };

      navigator.getUserMedia(medias, successCallback, errorCallback);


    } else {
      utils.stopCamera();
      onVideoStopped();
    }

  });

  function onVideoStarted() {
    streaming = true;
    startAndStop.innerText = 'Stop';
    start();
  }

  function onVideoStopped() {
    streaming = false;
    canvasContext.clearRect(0, 0, canvasOutput.width, canvasOutput.height);
    startAndStop.innerText = 'Start';
  }

  async function start() {
    let video = document.getElementById('videoInput');
    let src = new cv.Mat(video.height, video.width, cv.CV_8UC4);
    let dst = new cv.Mat(video.height, video.width, cv.CV_8UC4);
    let gray = new cv.Mat();
    let cap = new cv.VideoCapture(video);
    let faces = new cv.RectVector();
    let classifier = new cv.CascadeClassifier();


    let result = classifier.load("haarcascade_frontalface_default.xml");

    const FPS = 30;

    function processVideo() {
      try {
        if (!streaming) {
          // clean and stop.
          src.delete();
          dst.delete();
          gray.delete();
          faces.delete();
          classifier.delete();
          return;
        }
        let begin = Date.now();
        // start processing.
        cap.read(src);
        src.copyTo(dst);
        cv.cvtColor(dst, gray, cv.COLOR_RGBA2GRAY, 0);
        // detect faces.
        classifier.detectMultiScale(gray, faces, 1.1, 3, 0);
        // draw faces.
        for (let i = 0; i < faces.size(); ++i) {
          let face = faces.get(i);
          let point1 = new cv.Point(face.x, face.y);
          let point2 = new cv.Point(face.x + face.width, face.y + face.height);
          cv.rectangle(dst, point1, point2, [255, 0, 0, 255]);
        }
        cv.imshow('canvasOutput', dst);
        if(servo && faces.size() > 0){
           let face = faces.get(0);
            servo.angle((320-(face.x + face.width/2)) * 180 / 320);
        }

        // schedule the next one.
        let delay = 1000 / FPS - (Date.now() - begin);
        setTimeout(processVideo, delay);
      } catch (err) {
        console.error(err);
      }
    };

    // schedule the first one.
    setTimeout(processVideo, 0);

  }
</script>
</body>
</html>

まとめるとちょっと長いですね

これで完成です!

iPhoneを斜めに立てかけたかったのと,ファンの高さが足りなかったので,底上げしています

20180515_101959.GIF

8万円のiPhoneのカメラの性能はさすがで,解像度も速度もバッチリでした.
Javascriptでできることがだんだん増えているので,他にも色々楽しいことができそうです