Edited at

Mackerel と Raspberry Pi で作るお部屋監視システム


はじめに

最近、僕の部屋の中から勝手に色々な物を持っていく人がいまして、自宅は某警備会社にお願いしているのですが、僕の部屋のセキュリティが全然保たれていないなと感じたのでなんとかしようと思っていました。僕の部屋はベランダに抜ける為の部屋でもあるので洗濯物を干す為に鍵は掛けられません。金庫に入れるという方法もあるけど僕の使い勝手が下がります。そこで Raspberry Pi と Mackerel を使ってお部屋監視システムを作る事にしました。

泥棒


Raspberry Pi と Mackerel を連携する

Raspberry Pi を常時起動するにあたって CPU 温度が気になりました。省電力とは言えど、風が通らない部屋で夏場だと CPU の温度が60℃くらいになります。負荷を掛けると70℃を超えます。問題無いとは思いますが出来れば触って熱く無い程度には下がって欲しいのです。今年の7月頃から Mackerel のカスタムメトリクスを使って CPU 温度を計っていました。Raspberry Pi は USB を使って外部ディスクから起動する事も出来ますが(ただし 3B+ のみ)突起物が好きじゃないので(まだ) SD カードのまま使っています。SD カードは書き込み回数が多いと壊れてしまうので極力ログファイル出力をしたくありません。またメモリは1GBですが少しでも使用量を減らしたかったので mackerel-agent を使わず CPU 温度だけを、以下のスクリプトでポストしています。

#!/bin/sh

freq=$(awk '{printf("%.1f\n",$1/1e3)}' /sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq)
temp=$(awk '{printf("%.1f\n",$1/1e3)}' /sys/class/thermal/thermal_zone0/temp)
date=$(date +%s)
url=https://api.mackerelio.com/api/v0/services/raspberrypi-cpu-temp/tsdb
apikey=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
curl -s $url -H "X-Api-Key: $apikey" -H 'Content-Type: application/json' -X POST -d "[
{
\"name\": \"CPU.temp\", \"time\": $date, \"value\": $temp},
{
\"name\": \"CPU.freq\", \"time\": $date, \"value\": $freq}
]"
2>&1 > /dev/null

これを cron で1時間毎に実行しています。6ヶ月間取り続けた結果が以下になります。

Raspberry Pi の CPU 温度

9月末に CPU の温度がガクッと下がっているのは、夏場で60℃近くに達した CPU 温度に不安を感じてヒートシンクと小型ファンを取り付けた効果です。およそ15~20℃くらい下がります。ちなみにラズパイのケース次第ですがケースの底面に空気が通る様にゲタを置いてあげるだけでも5℃くらい下がります。

あとは Mackerel で監視ルール設定をしておけば、例えばファンが壊れて CPU 温度が上昇した場合でも検出できます。

アラート設定

これで安心して常時稼働できる様になりました。


監視システムとして Mackerel を選んだ訳

監視システムに Mackerel を選んだのには訳があります。通常、自分でアラートシステムを作る場合、何かの異常を判断してそれが解除されるまでを異常と扱います。そして異常と判断した場合にメールや LINE 通知を行う事になるのですが、異常を判定する度に通知を行ってしまうとスマホが通知で溢れてしまいます。Mackerel のアラート機能を使う事で【通常から発生】または【発生から解除】のみ通知を送る事が出来るのです。

これだけでもありがたい話です。


監視の方法は画像の物体判定

画像から人物を判定する方法としては tensorflow を使いました。OpenCV を使ってモーション検知する方法も考えましたが、もともと cron で動かしたいという事もあったので単発起動できる方法を選びました。

言語はGo言語を選びました。Go言語から tensorflow を使うには libtensorflow.so が必要になります。

golang で tensorflow のススメ

コードは以下の通り。

package main

import (
"bufio"
"bytes"
"encoding/json"
"flag"
"fmt"
"go/build"
"image"
"image/color"
"image/draw"
"image/jpeg"
"io/ioutil"
"log"
"os"
"path/filepath"

"golang.org/x/image/bmp"
"golang.org/x/image/colornames"
"golang.org/x/image/font"
"golang.org/x/image/font/basicfont"
"golang.org/x/image/math/fixed"

tf "github.com/tensorflow/tensorflow/tensorflow/go"
"github.com/tensorflow/tensorflow/tensorflow/go/op"
)

type jsonResult struct {
Name string `json:"name"`
Probability float64 `json:"probability"`
}

func drawString(img *image.RGBA, p image.Point, c color.Color, s string) {
d := &font.Drawer{
Dst: img,
Src: image.NewUniform(c),
Face: basicfont.Face7x13,
Dot: fixed.Point26_6{fixed.Int26_6(p.X * 64), fixed.Int26_6(p.Y * 64)},
}
d.DrawString(s)
}

func drawRect(img *image.RGBA, r image.Rectangle, c color.Color) {
for x := r.Min.X; x <= r.Max.X; x++ {
img.Set(x, r.Min.Y, c)
img.Set(x, r.Max.Y, c)
}
for y := r.Min.Y; y <= r.Max.Y; y++ {
img.Set(r.Min.X, y, c)
img.Set(r.Max.X, y, c)
}
}

func loadLabels(name string) ([]string, error) {
f, err := os.Open(name)
if err != nil {
return nil, err
}
defer f.Close()

scanner := bufio.NewScanner(f)
var labels []string
for scanner.Scan() {
labels = append(labels, scanner.Text())
}
if err := scanner.Err(); err != nil {
return nil, err
}
return labels, nil
}

func decodeBitmapGraph() (*tf.Graph, tf.Output, tf.Output, error) {
s := op.NewScope()
input := op.Placeholder(s, tf.String)
output := op.ExpandDims(
s,
op.DecodeBmp(s, input, op.DecodeBmpChannels(3)),
op.Const(s.SubScope("make_batch"), int32(0)))
graph, err := s.Finalize()
return graph, input, output, err
}

func makeTensorFromImage(img []byte) (*tf.Tensor, image.Image, error) {
tensor, err := tf.NewTensor(string(img))
if err != nil {
return nil, nil, err
}
normalizeGraph, input, output, err := decodeBitmapGraph()
if err != nil {
return nil, nil, err
}
normalizeSession, err := tf.NewSession(normalizeGraph, nil)
if err != nil {
return nil, nil, err
}
defer normalizeSession.Close()
normalized, err := normalizeSession.Run(
map[tf.Output]*tf.Tensor{input: tensor},
[]tf.Output{output},
nil)
if err != nil {
return nil, nil, err
}

r := bytes.NewReader(img)
i, _, err := image.Decode(r)
if err != nil {
return nil, nil, err
}
return normalized[0], i, nil
}

func detectObjects(session *tf.Session, graph *tf.Graph, input *tf.Tensor) ([]float32, []float32, [][]float32, error) {
inputop := graph.Operation("image_tensor")
output, err := session.Run(
map[tf.Output]*tf.Tensor{
inputop.Output(0): input,
},
[]tf.Output{
graph.Operation("detection_boxes").Output(0),
graph.Operation("detection_scores").Output(0),
graph.Operation("detection_classes").Output(0),
graph.Operation("num_detections").Output(0),
},
nil)
if err != nil {
return nil, nil, nil, fmt.Errorf("Error running session: %v", err)
}
probabilities := output[1].Value().([][]float32)[0]
classes := output[2].Value().([][]float32)[0]
boxes := output[0].Value().([][][]float32)[0]
return probabilities, classes, boxes, nil
}

func main() {
var jsoninfo bool
var probability float64
var dir string
var output string

flag.BoolVar(&jsoninfo, "json", false, "Output JSON information (instead of output image)")
flag.Float64Var(&probability, "prob", 0.4, "Probability")
flag.StringVar(&dir, "dir", filepath.Join(filepath.SplitList(build.Default.GOPATH)[0], "src/github.com/mattn/go-object-detect-from-image"), "Directory containing the trained model and labels files")
flag.StringVar(&output, "output", "output.jpg", "Output file name")
flag.Parse()

model, err := ioutil.ReadFile(filepath.Join(dir, "frozen_inference_graph.pb"))
if err != nil {
log.Fatal(err)
}

labels, err := loadLabels(filepath.Join(dir, "coco_labels.txt"))
if err != nil {
log.Fatal(err)
}

graph := tf.NewGraph()
if err := graph.Import(model, ""); err != nil {
log.Fatal(err)
}

session, err := tf.NewSession(graph, nil)
if err != nil {
log.Fatal(err)
}
defer session.Close()

var f *os.File
if flag.NArg() == 1 {
f, err = os.Open(flag.Arg(0))
if err != nil {
log.Fatal(err)
}
defer f.Close()
} else {
f = os.Stdin
}
img, _, err := image.Decode(f)
if err != nil {
log.Fatal(err)
}

var buf bytes.Buffer
err = bmp.Encode(&buf, img)
if err != nil {
log.Fatal(err)
}

tensor, img, err := makeTensorFromImage(buf.Bytes())
if err != nil {
log.Fatalf("error making input tensor: %v", err)
}

probabilities, classes, boxes, err := detectObjects(session, graph, tensor)
if err != nil {
log.Fatalf("error making prediction: %v", err)
}

if jsoninfo {
var result []jsonResult
i := 0
for float64(probabilities[i]) > probability {
idx := int(classes[i])
result = append(result, jsonResult{
Name: labels[idx],
Probability: float64(probabilities[idx]),
})
i++
}
json.NewEncoder(os.Stdout).Encode(result)
return
}

bounds := img.Bounds()
canvas := image.NewRGBA(bounds)
draw.Draw(canvas, bounds, img, image.Pt(0, 0), draw.Src)
i := 0
for float64(probabilities[i]) > probability {
idx := int(classes[i])
y1 := int(float64(bounds.Min.Y) + float64(bounds.Dy())*float64(boxes[i][0]))
x1 := int(float64(bounds.Min.X) + float64(bounds.Dx())*float64(boxes[i][1]))
y2 := int(float64(bounds.Min.Y) + float64(bounds.Dy())*float64(boxes[i][2]))
x2 := int(float64(bounds.Min.X) + float64(bounds.Dx())*float64(boxes[i][3]))
drawRect(canvas, image.Rect(x1, y1, x2, y2), color.RGBA{255, 0, 0, 0})
drawString(
canvas,
image.Pt(x1, y1),
colornames.Map[colornames.Names[idx]],
fmt.Sprintf("%s (%2.0f%%)", labels[idx], probabilities[idx]*100.0))
i++
}

out, err := os.Create(output)
if err != nil {
log.Fatal(err)
}
defer out.Close()

err = jpeg.Encode(out, canvas, nil)
if err != nil {
log.Fatal(err)
}
}

リポジトリ: https://github.com/mattn/go-object-detect-from-image

ちょっと長いですが、コマンドラインから

$ ./go-object-detect-from-image input.jpg

とすれば output.jpg にマーカーの付いた画像が出力されます。また引数を省略すれば標準入力から画像を読み込んで処理するので raspistill コマンドの -o - を使えば SD に書き込む事無く処理できます。

実際に実行すると以下の様になります。

肉まんいる?

これで突然自宅で「肉まん食べる?」って言われても...

スイカ

スイカの代わりにさせられてしまっても...

ぬこ

ぬこー

尻から火

こんな危ない奴が自宅に来ても大丈夫。

なお実際には物体の検出だけ出来ればいいので画像は出力しません。go-object-detect-from-image には -json オプションが用意してあり、お尻から火を吹く男性の画像であれば以下の様に出力されます。

[{"name":"1: person","probability":0.9966573715209961}]

この例だとほぼ 100% 人物だと認識しています。また複数人いればその数だけ配列で出力されます。

さてこの json だけ得られれば後は簡単。

#!/bin/bash

cd $HOME/go/src/github.com/mattn/go-object-detect-from-image
export TF_CPP_MIN_LOG_LEVEL=3
count=$(raspistill -rot 180 -w 800 -h 600 -o - | ./go-object-detect-from-image -json | ruby -rjson -e 'puts JSON.parse(STDIN.read).select{|x| x["name"].include? "person"}.length')
date=$(date +%s)
url=https://api.mackerelio.com/api/v0/services/object-detect/tsdb
apikey=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
curl -s $url -H "X-Api-Key: $apikey" -H 'Content-Type: application/json' -X POST -d "[
{
\"name\": \"object.count\", \"time\": $date, \"value\": $count}
]"
2>&1 > /dev/null

こうしておけばサービスメトリクス object.count に検出した人物の数がポストされます。猫の数を数えたい人は personcat にして下さい。

人物の数

また監視設定で以下の様に設定すれば部屋に人が入ってくればアラートが上がる様になります。

監視設定

アラート

さらに通知先として LINE を追加しておけば完璧。

フロー

あとはこのシェルを平日の僕が不在にしている時間帯に cron 起動しておけばOK。

まぁこの監視システムを作る前から、犯人は娘という事は分かってはいるのですが。