こまちゃん監視システムをPicasaからCouchDBに切り替えた。

3.11の震災をきっかけに、 こまちゃん監視サーバを構築しました が、運用しはじめて問題が3つほど出てきました。

  • 写真のアップロード先をPicasaにしていましたが、1アルバムへのアップロード数の上限が1000ファイルまでと少ない
    • こまちゃんが写らないときは、30分毎のスナップショットだけなので問題ありませんが、写る場合は数秒で数十枚は撮影されます。なので写る場合の時間当たりの平均枚数は100-200枚になるため、すぐに上限に達してしまいます
    • そのためアップロードとは別に定期的に削除が必要でした
  • 一度にアップロードまたは削除するファイル数が多いとエラーになる
    • これはアップロード用に使っているgoogleclだけでなく、webブラウザ経由でも同じ問題です
    • また、googleclでは写真の検索が期待値通りにならない、という問題もあります [1]
  • 致命的なのは、ヨメがGoogleアカウントのID、パスワードを覚えないので、 「こまちゃんの写真を見られない」と頻繁に言われる
    • 原因はキャッシュが切れてログアウトされているのに、ログインしないままアクセスしたり、ログインしてもアクセス権限のないID, パスワードを入力していたりといったところです。 [2]
    • これらはシステム的な問題ではありませんが、ヨメがログインとかを気にせずに使えるようにする必要があります。
      • 普通の人には複数のアカウントを管理して、パスワードも別にしておいて、というのは結構敷居が高いのですね。

上記の問題をクリアして、簡単にDBとアプリをコピーできるようにする必要があるので、データのアップロード先をPicasaからCouchDBに変更しました。

アップロード先をCouchDBに変更する上での仕様

ざっくりやること決めて、最低限の機能を実装したので、まとめてみました。

  • こまちゃん監視カメラで取得した写真画像を、撮影された時刻と一緒にアップロードする
  • 時間単位でサムネイルをプレビューできるようにする
  • 定期的なスナップショットは不要とする
  • 撮影された時間単位で写真の枚数を表示し、過去に遡る形で10件(断続的に10時間分)をリストする
  • 指定したサムネイルから実際の画像ファイルを表示する

という感じです。詳細は次のとおりです。 ソースコードはPicasaの時と同様に、 github に晒しています。

アップロード処理

  • オリジナル画像からサムネイルを生成する
'''Generating thumbnail.'''
 def generateThumbnail(self):
     import Image, os
     image = Image.open(self.filename,mode='r')
     image.thumbnail([60,60])
     self.thumbnail_name = os.path.splitext(self.filename)[0] + "_s.jpg"
     image.save(self.thumbnail_name)
  • オリジナル画像とサムネイルは撮影時刻などのメタ情報を紐付けるため、CouchDBのStandaloneの添付ファイルを使わず、ドキュメントのインラインの添付ファイルにする
     '''generate dict as document.'''
     def generateDict(self):
         self.doc = {
 (snip)
             "photo":self.filename,
             "thumbnail":self.thumbnail_name,
             "_attachments":{
                 self.filename:
                     {
                     "content_type":"image/jpeg",
                     "data":self.image_base64
                     },
                 self.thumbnail_name:
                     {
                     "content_type":"image/jpeg",
                     "data":self.thumbnail_base64
                     }
                 }
             }



* インラインの添付ファイルにするため、画像ファイルはbase64にエンコードする
'''Encoding photo image file to base64 ascii strings.'''
def encodeBase64(self, image):
    import base64
    return base64.encodestring(open(image,"rb").read())
  • 時刻情報などのメタ情報は、画像のファイル名から取得する
    • 当初、ctimeから取得しようとしましたが、撮影された時刻と、ファイルが生成されてファイルシステムに書き込まれた時刻に30-60秒ほどのタイムラグがあるので、撮影された時刻が正確なファイル名から取得することにしました
'''Getting date info from filename.'''
def getDate(self):
    import re
    t = re.match('^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})',
                 re.match('(\d+)-(\d+)-(\d+)-(\d).jpg', self.filename)
                 .group(2))
    self.year = t.group(1)
    self.mon = t.group(2)
    self.mday = t.group(3)
    self.hour = t.group(4)
    self.min = t.group(5)
    self.sec = t.group(6)
  • メタ情報とbase64にエンコードした画像をJSONにする
    • 1画像=1ドキュメントです
  • まとめて複数画像をアップロードするため、CouchDBの_bulk_docs APIを使うために、単一のJSONオブジェクトにする
'''Serializing JSON for bulk_docs.'''
def serializedJson(self):
    import json
    self.bulk_docs = json.JSONEncoder().encode({
            "all_or_nothing":"true",
            "docs":self.docs
            })
  • JSON化済みの画像は重複アップロードを防ぐために、削除する
# remove jpg files.
             os.remove(self.filename)
             os.remove(self.thumbnail_name)

上記のJSONに変換するための処理のコードは こちら です。

次に、実際のファイルをアップロードするための処理は今回はcurlコマンドで_bulk_docs APIにPOSTするだけにしました。

  • 複数の画像をまとめたJSONをアップロードする(_bulk_docs)
curl -X POST -H 'Content-Type:application/json' -d @${JSON} \
         http://${USER}:${PASS}@${HOSTNAME}/${DB}/_bulk_docs

コードは これ

CouchDBの処理

CouchDB側はデータストア兼画像ビューワーなので、前述の通り、撮影された時間帯のリストアップ、時間帯毎にサムネイルの一覧表示、選んだサムネイルを拡大表示、前後に撮影した写真の直接表示ができればOKです。

  • ドキュメントの年(year)、月(mon)、日(mday)、時(hour)の配列をキーに、_view/dateで一時間当たりの画像の総数を取得する

with-couchdb/petviewer/views/date/map.js

 function(doc) {
     if (doc.photo) {
         var arrayDate = [doc.year, doc.mon, doc.mday, doc.hour];
         emit(arrayDate, 1);
     }
 }





* reduce処理で一時間当たりのMap処理の結果の総数を算出する

with-couchdb/petviewer/views/date/reduce.js

function(keys,values) {
    return sum(values);
}

追記

CouchDBのReduce処理には組み込み関数が用意されているので、上記のようにsumを行う場合は、

_sum

とするだけでも大丈夫です [3] 。他には基本的な統計情報を出すための_stat関数も用意されています。

  • MapReduce処理の結果から時間帯毎に画像の件数を取得し、mustacheとjQuery Mobileを使ってリンクリストにする [4]

with-couchdb/petviewer/lists/hours.js

(snip)
    var datalist = [];
    var row;
    while (row = getRow()) {
        datalist.push({
            year: row.key[0],
            mon: row.key[1],
            mday: row.key[2],
            hour: row.key[3],
            num: row.value
        });
    }
(snip)

with-couchdb/petviewer/templates/hours.html

(snip)
      <div data-role="content">
        <ul data-role="listview">
          {{#datalist}}
          <li><a href="../hour/thumbnail?key=%22{{year}}{{mon}}{{mday}}{{hour}}%22" data-ajax="false">{{mon}}/{{mday}} {{hour}}時</a><span class="ui-li-co
unt">{{num}}</li>
          {{/datalist}}
        </ul>
      </div>
(snip)
  • “YYYYMMDDhh”を検索キーとして_view/thumbnailを取得する

with-couchdb/petviewer/views/thumbnail/map.js

function(doc) {
    if(doc.photo) {
            emit(doc.year + doc.mon + doc.mday + doc.hour, doc);
    };
}
  • _view/thumbnailの結果を_list/hourに渡して、mustacheでサムネイルの一覧画面を生成する

with-couchdb/petviewer/lists/hour.js

(snip)
    var datalist = [];
    var row;
    while (row = getRow()) {
        datalist.push({
            _id: row.value._id,
            thumbnail: row.value.thumbnail,
            photo: row.value.photo,
            year: row.value.year,
            mon: row.value.mon,
            mday: row.value.mday,
            hour: row.value.hour,
            min: row.value.min,
            sec: row.value.sec
        });
    }
(snip)
}

with-couchdb/petviewer/templates/hour.html

(snip)
      <div data-role="content">
          {{#datalist}}
          <span>
            <a href="../../_show/photo/{{_id}}" data-ajax="false">
              <img id="thumbnail" src="../../../../{{_id}}/{{thumbnail}}"
                   alt="{{year}}/{{mon}}/{{mday}} {{hour}}:{{min}}:{{sec}}"/></a>

          {{/datalist}}
      </div>
(snip)
  • photo showで、画像の表示を行う

with-couchdb/petviewer/shows/photo.js

function (doc, req) {
(snip)
    data = {
        _id: doc._id,
        photo: doc.photo,
        year: doc.year,
        mon: doc.mon,
        mday: doc.mday,
        hour: doc.hour,
        min: doc.min,
        sec: doc.sec
    };
(snip)
  • 画像の表示画面では時系列で前後の画像表示画面に直接遷移できるようにする
    • ドキュメントには時系列で前後のドキュメントの情報は持っていません。なので、JavaScriptで、時刻(YYYYMMDDhh)をキーに_view/thumbnailから一時間あたりの全ドキュメントのdoc._idをJSONで取得し、それから前後の写真をそれぞれ指すdoc._idを配列として保持し、表示している写真のdoc._idをキーに前後を検索し、リンクを作ります

with-couchdb/petviewer/_attachments/js/pointer.js

function getSearchKey() {
    return $('input#searchkey').val();
}

function basename(path) {
    return path.replace(/\\/g,'/').replace( /.*\//, '' );
}

function getDocId () {
    return basename(window.location.pathname);
}

function getJsonUri() {
    return "../../_view/thumbnail?key=%22" + getSearchKey() + "%22";
}

function getThumbnailListUri() {
    return "../../_list/hour/thumbnail?key=%22" + getSearchKey() + "%22";
}

$.getJSON(getJsonUri(),
          function(data) {

              var id = [];
              var nextid = [];
              var previd = [''];
              var idlist = new Array();

              // parse JSON.
              $.each(data, function(key, val) {

                  if (data.rows) {
                      $.each(val, function(key2, val2) {

                          $.each(val2, function(key3, val3) {

                              if (key3 == "id") {
                                  id.push(val3);
                              }
                          });
                      });
                  }
              });

              // next id list.
              nextid = id.slice(0);
              nextid.shift();
              nextid.push('');

              // previous id list.
              previd = previd.concat(id.slice(0));
              previd.pop();

              // Array idlist is [["id", "previd", "nextid"], [],...]
              for (var i in id) {
                  idlist.push([
                      id[i],
                      previd[i],
                      nextid[i]
                  ]);
              }

              // search previd, nextid by id.
              for (var i = 0; i < idlist
[1]ドキュメントどおりの正規表現の結果にならないという…。
[2]Googleアカウントだけでなく、Google Appsのアカウントもあるのです。
[3]というか、このブログを書いた後に、 Writing and Querying MapReduce Views in CouchDB を読んで知りました。
[4]mustacheとjQuery Mobileについては、 以前のエントリ を参照のこと。