退魔の剣士の動向を把握したい

本投稿は「ゼルダの伝説　ティアーズ　オブ　ザ　キングダム」に登場するとある団体の構成員の立場で記載しており、実在の人物・団体とは一切関係ありません。シナリオなどの情報は極力記載しませんが少しは入ってしまうことをご了承ください。

# はじめに

最近ちょっと困ったことがありまして、行方不明だった退魔の剣士が最近活動を再開したらしいんですよ。
それからというもの、うちの拠点に攻め込んで来て警備の団員を倒した上、貴重な食料や設計図を持っていかれてしまう事件が続いているんですよね。
とはいえ相手は魔物の巣窟に単身乗り込んで殲滅したりするような奴です。最近だと戦車や戦闘ロボに乗って攻めてくるというような噂もあります。正直怖い。
せめて居所を把握して、拠点に近づいてきたら速やかに防衛や避難の準備などをしたいところです。
ということで今回はあの剣士の位置を把握して共有するシステムを開発してみました。
~~団体の性質上、実際やるとなるとアカウント作成させてくれなくて詰みそうではありますが。~~

# システム構成

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/542185/0450533e-24ec-32b8-3ab8-6c1e59cf23ca.png)

まず位置情報はどこから取れるのかを検討します。奴はプルアパッドというなんかすごい端末を持ってて、そこに表示されているミニマップを見ながら探索しており、ミニマップの中には周囲の地図と位置情報が表示されているようです。これを何らかの方法で見ていれば位置情報は把握できそうです。

今回は[ソラコム](https://soracom.jp/)が出している[ソラカメ](https://soracom.jp/sora_cam/)を使います。これは初期費用3480円でクラウドに常時録画できるカメラサービスで、録画データはWeb APIを使って動画や静止画でエクスポートしてくることもできる便利なサービスです。対応カメラである[ATOM Cam Swing](https://soracom.jp/store/22020/)を持っていたのでこのサービスが使えます。ミニマップが中央になるようにカメラの位置や向きを調整すれば良いでしょう。

アップロードされた動画からどうやって位置情報を切り出すかですが、今回はAWSの画像解析サービスである[Amazon Rekognition](https://aws.amazon.com/jp/rekognition/)を使ってみました。色々な解析ができますが、[画像内に存在するテキストを検出する機能](https://docs.aws.amazon.com/ja_jp/rekognition/latest/dg/text-detection.html)があり、テキスト内容とその位置が取得できるので、これが使えそうです。ソラカメにアップロードされた動画を時刻指定で静止画エクスポートし、画像をRekognitionに送れば縦、横、高さがそれぞれ取得できます。

位置情報が取得できたら、情報の可視化を考えましょう。ソラコムを使うのであればついでにSORACOM Lagoonでの可視化ができれば楽そうです。地図表示はあるのですがこれは地球の地図に対応したもので、ハイラルの地図には対応していません。[Soracom X/Y Imageパネル](https://users.soracom.io/ja-jp/docs/lagoon-v3/visualization-soracom-xy-image/)を使えば、任意の背景画像に対して座標となるデータを送信すると、最新の位置とその軌跡を表示することができます。

表示はこれが使えそうなので、データソースとなるSORACOM Harvestに対してデータをアップロードします。なんらかのリソースと結びつける必要がありますが、[SORACOM Inventoryのデバイスを作ってPublish APIを使う](https://users.soracom.io/ja-jp/docs/inventory/harvest/)のが一番楽なので、そのように設定します。

# コード

コードとしてはこんな感じです。だいぶ雑に書いてしまいましたが。。x、y、zのそれぞれの位置はテスト的に何枚か解析させた画像からある程度のマージンを持たせて判定させています。カメラの撮影位置や角度などで抜き出す位置は違うので注意しましょう。

```rb
require 'net/http'
require 'json'
require 'base64'
require 'aws-sdk-s3'
require 'aws-sdk-rekognition'

def lambda_handler(event:, context:)
    timestamp = Time.now.to_i * 1000 - 60000

    # 認証
    url = URI.parse('https://api.soracom.io/v1/auth')
    http = Net::HTTP.new(url.host, url.port)
    http.use_ssl = true
    headers = {
        'Accept' => 'application/json',
        'Content-Type' => 'application/json'
    }
    data = {
        authKeyId: ENV['SORACOM_AUTH_KEY_ID'],
        authKey: ENV['SORACOM_AUTH_KEY'],
    }

    request = Net::HTTP::Post.new(url.path, headers)
    request.body = data.to_json
    response = http.request(request)
    auth = JSON.parse(response.body)
    token = auth["token"]
    api_key = auth["apiKey"]
    
    # 画像エクスポート
    url = URI.parse("https://api.soracom.io/v1/sora_cam/devices/#{ENV["SORACAM_ID"]}/images/exports")
    http = Net::HTTP.new(url.host, url.port)
    http.use_ssl = true
    headers = {
        'Accept' => 'application/json',
        'Content-Type' => 'application/json',
        "X-Soracom-API-Key" => api_key,
        "X-Soracom-Token" => token
    }
    data = {
        "imageFilters" => ["wide_angle_correction"],
        "time" => timestamp
    }

    request = Net::HTTP::Post.new(url.path, headers)
    request.body = data.to_json
    response = http.request(request)
    export = JSON.parse(response.body)
    export_id = export["exportId"]
    
    image_url = ""
    while true do
        sleep 2
        url = URI.parse("https://api.soracom.io/v1/sora_cam/devices/#{ENV{"SORACAM_ID"}}/images/exports/#{export_id}")
        http = Net::HTTP.new(url.host, url.port)
        http.use_ssl = true
        
        headers = {
            'Accept' => 'application/json',
            'X-Soracom-API-Key' => api_key,
            'X-Soracom-Token' => token
        }

        request = Net::HTTP::Get.new(url.path, headers)
        response = http.request(request)
        status = JSON.parse(response.body)
        if status["status"] == "completed"
            image_url = status["url"]
            break
        elsif status["status"] == "failed" || status["status"] == "limitExceeded" || status["status"] == "expired"
            puts "Failed"
            return { statusCode: 400, body: "get image failed" }
        else
            puts "Processing"
        end
    end

    # 画像取得
    puts image_url
    url = URI(image_url)
    response = Net::HTTP.get_response(url)
    image_body = response.body

    # 画像をRekognitionで解析
    rekog = Aws::Rekognition::Client.new(region: "ap-northeast-1")
    detect_result = rekog.detect_text({
      image: {
        bytes: Base64.encode64(image_body)
      }
    })
    detects = detect_result.text_detections.map do |detect|
        ({ "text" => detect.detected_text, "confidence" => detect.confidence, "left" => detect.geometry.bounding_box.left, "right" => detect.geometry.bounding_box.left + detect.geometry.bounding_box.width, "top" => detect.geometry.bounding_box.top, "bottom" => detect.geometry.bounding_box.top + detect.geometry.bounding_box.height })
    end
    puts detects

    # 位置情報を取得
    upload_data = {}
    x = detects.find { |detect| detect["left"] >= 0.25 && detect["left"] <= 0.35 && detect["right"] >= 0.36 && detect["right"] <= 0.46 && detect["top"] >= 0.72 && detect["top"] <= 0.82 && detect["bottom"] >= 0.86 && detect["bottom"] <= 0.96 } 
    if !x.nil?
      x_match = x["text"].match(/^\-?\d{4}$/)
      if !x_match.nil?
        upload_data["x"] = x_match.to_s.to_i
      end
    end

    y = detects.find { |detect| detect["left"] >= 0.37 && detect["left"] <= 0.47 && detect["right"] >= 0.49 && detect["right"] <= 0.59 && detect["top"] >= 0.79 && detect["top"] <= 0.89 && detect["bottom"] >= 0.88 && detect["bottom"] <= 0.98 } 
    if !y.nil?
      y_match = y["text"].match(/^\-?\d{4}$/)
      if !y_match.nil?
        upload_data["y"] = y_match.to_s.to_i
      end
    end

    z = detects.find { |detect| detect["left"] >= 0.52 && detect["left"] <= 0.62 && detect["right"] >= 0.61 && detect["right"] <= 0.71 && detect["top"] >= 0.64 && detect["top"] <= 0.74 && detect["bottom"] >= 0.80 && detect["bottom"] <= 0.90 } 
    if !z.nil?
      z_match = z["text"].match(/^\-?\d{4}$/)
      if !z_match.nil?
        upload_data["z"] = z_match.to_s.to_i
      end
    end
    
    if upload_data.key?("x") && upload_data.key?("y") && upload_data.key?("z")
        # 位置情報をHarvestにアップロード
        p upload_data
        
        url = URI.parse("https://api.soracom.io/v1/devices/#{ENV["SORACOM_DEVICE_ID"]}/publish")
        http = Net::HTTP.new(url.host, url.port)
        http.use_ssl = true
        headers = {
            'X-Device-Secret' => ENV["SORACOM_DEVICE_SECRET"]
        }
        data = upload_data
    
        request = Net::HTTP::Post.new(url.path, headers)
        request.body = data.to_json
        response = http.request(request)
        puts response.body
        return upload_data
    else
        return "Not found"
    end
end
```

これであとはLambdaをEventBridgeなどで定期的に起動すれば、あの剣士の位置情報が可視化されるはずです。やってみましょう！

# 結果

こんな感じで軌跡と現在位置が表示されました！リアルタイムで1分ごとに更新されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/542185/dac83c2c-1502-0898-c5e2-050d390a1c34.png)

1分ごとなのでまあまあ粗いですが、だいたいどんなところを通ってきているかは把握できますね。今は監視砦の近くまで来ているようです。

# 課題

これで実戦投入！といきたいところだったのですが、致命的な課題が出てしまいました。位置情報の「-(マイナス)」の検出率がかなり低いんですよね。

例えばこの画像の縦位置は正しく「-3759」と検出されるのですが、
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/542185/d9909225-7de9-ef4b-7004-23dc84f3d69a.png)

この画像だと「3521」とマイナスなしで検出されてしまいます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/542185/17c6edd9-9b69-043f-29ae-6b7c876c4a08.png)

たしかに上の画像に比べて鮮明さが足りない感じはしますが、このくらいは普通にあり得えますので読み取って欲しいです。Rekognitionでの境界線を見ると、円形に文字を沿わせている形なのも読み取りにくい原因なのかも。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/542185/cb56f9af-75e4-512d-4533-a01284dd770e.png)

また、マップ上に表示される色々なアイコンと重なると正しく読めなくなってしまいます。幸い数字部分は0埋め4桁なので、数字4桁でなければ異常と判断できるのですが、マイナスはないこともあり得るので異常と判断するのも難しい。
この辺りが標準的な解析の限界なのかもしれません。元画像を読み取りやすいように加工するとか、解析モデルを変えるとかで精度は上げられるのではないかと思いますが、画像解析の分野にはあまり詳しくないので、今回はここまでにします。

# 終わりに

最後まで詰めきれませんでしたが、うまくいけば色々発展させられそうです。
特定の地域に入られたり拠点に近づかれたら警報を送るとかもできそうですし、
魔物の生息位置と合わせてどのくらい戦闘してきているかを推測するとかもできそう。

なんとかあの剣士の侵攻を止める仕組みを作らねば。
コーガ様に栄光あれ。