僕たちはまだ本当の星空を知らない(stellarium+headlss+iOS)

この記事はHamee Advent Calendar 2018の5日目の記事です。

皆さん最近星見てますか。
「忙しくて星なんか見ている暇ない」、「外に行くのが寒い」、「星なんか見るよりSoftwareDesign読んだり、開発していたほうが楽しい」という方必見ですわ。 全部叶えてあげる。

今月のSoftwareDesignにお家でプラネタリウムできるソフト、「stellarium」が紹介されていた。 記事を見ると中々にそそられたしめっちゃ簡単そうだったのでやってみた。 stellariumは下のように実際の星空をバーチャルで見ることができる。

f:id:rasukarusan:20181128002147p:plain

構成

今回やりたいことはiPhoneで撮った写真に本当の星空を合成するというもの。
Docker上にstellariumを構築して、iPhoneからstellarium起動バッチを叩くみたいなイメージ。

f:id:rasukarusan:20181205003931p:plain

Dockerでのstellariumの環境構築は別記事でやったが、出力するディスプレイがないと言われるところで終了した。

$ ./src/stellarium
QXcbConnection: Could not connect to display
Aborted

まずはこの問題を解決し、headlessでstellariumを起動しスクリーンショットを撮れるようにする。

stellariumの動き

stellariumの起動してからの動きはざっくり言うとこう。

f:id:rasukarusan:20181205004202p:plain

まず初めに言っておきたいのは、そもそもstellariumはheadlessモードが存在しない。
./stellarium --headlessみたいな感じで起動できるんかなと思ってたらそんなものオプションは存在しなかった。いやまあグラフィックを楽しむものだから当然といえばそりゃそうなんだが。
ということで上記イメージの動きの中のどこかでheadlessな状態を仕込むしかない。

  • 案1:C++でOpenGLをheadlessで起動できるようにハイパープログラミングする
  • 案2:出力するディスプレイを仮想化してheadlessを実現する

・・・

いやC++わからんがな。そもそもOpenGLを全然知らんがな。却下。

出力するディスプレイを仮想化する

これだ、これしかない、一番楽そうだ・・・!(つーか これが限界)
というわけで早速いこう。

# GUIをheadlessで起動させるための仮想環境を立ち上げる
$ Xvfb :10 -ac -screen 0 1024x768x24 &

# スクリプトとして実行した場合、環境変数が読み込めないので実行時に環境変数を設定
$ export DISPLAY=":10"

# stellarium実行
$ ./src/stellarium

フィニッシュです。楽すぎワロタ。以下のような感じの出力されたらOK。

f:id:rasukarusan:20181205004350g:plain

Xvfbってなんや

今回DISPLAY出力を仮想化させるのに用いたXvfb。どうやら仮想ディスプレイを作るのによく使われる手段らしい。
FireFoxなどのブラウザをheadlessで起動しようぜ!などによく使われるとかなんとか。

Xvfbのインストールはapt-getでいける。

$ sudo apt-get install xvfb

一応コマンドの意味を記載。

オプション 意味
:10 仮想ディスプレイの番号を10番に設定(0番はXサーバーが使用済みなのでそれ以外だったら何番でも良い)
-ac disable access control restrictions。外部からの起動を許可。
-screen 0 スクリーンをOFF。headlessモード。
1024x768x24 画面サイズを1024x768に設定。色数を24bit(フルカラー)。

画面にアクセスしたら星空の画像を表示させる

無事GUIがない環境でstellariumをheadless起動することに成功したので、次は実際に外部からアクセスしたときにstellariumでスクリーンショットを撮ってみる。
f:id:rasukarusan:20181205004524p:plain

スクリーンショットを撮るスクリプトを作成

stellariumは起動時のオプション--startup-script $SCRIPT_PATHでstellarium用のスクリプトを実行することができる。
なのでまずはスクリーンショットを撮るスクリプトの作成から。スクリプトはstellarium/scriptssscという拡張子で配置します。(配置場所はbuilds/unix/scriptじゃないので注意)

screencapture.ssc

StelMovementMgr.zoomTo(180) ;
// 緯度・経度を指定
core.setObserverLocation(35.685, 139.751, 0);
StelMovementMgr.lookEast();
core.wait(0.0);
// 'hosizora.png'という名前でスクリーンショットが保存される
core.screenshot('hosizora', false, '', true);
core.quitStellarium();

あとスクリーンショットを保存するディレクトリをstellariumディレクトリ直下に作成しておきましょう。

$ mkdir stellarium/screenshots

これで実行時にスクリーンショットの保存先ディレクトリとスクリプトのscreencapture.sscを指定して起動する。

./builds/unix/src/stellarium --screenshot-dir screenshots --startup-script screencapture.ssc

確認すると

$ ls -l screenshots
total 752
-rw-r--r--  1 root  root  382765 12  3 22:50 hosizora.png

きっちり撮れてますね。

画面からアクセスしてスクリーンショットを表示する

PHPで行きます。配置は以下。

docker-stellarium
    |_docker-compose.yml
    |_web
      |_Dockerfile
      |_index.php
      |_stellarium
        |_exec.sh # stellariumの起動スクリプト
        |_screenshots
        |_scripts
        |_builds/unix
          |_unix
            |_src
              |_stellarium # stellarium本体
        |_...省略

あとDocker内でstellariumをビルドしたときに/root/.stellariumが作られる。ここにはconfig.iniなどstellariumの起動時に必要なファイルが置かれている。 画面からアクセスするとwww-dataがユーザーになるので、

$ cp -rf /root/.stellarium /home/www-data
$ chown -R www-data:www-data /home/www-data

で必要なファイルをコピーしておく。 それに伴いstellariumの起動スクリプトの方も少し変更しておく。

exec.sh

#!/bin/sh

# GUIをheadlessで起動させるための仮想環境
Xvfb :10 -ac -screen 0 1024x768x24 &

# スクリプトとして実行した場合、環境変数が読み込めないので実行時に環境変数を設定
export DISPLAY=":10"
export HOME=/home/www-data

# stellariumの--screenshot-dirや--startup-scriptオプションはフルパスじゃないと読み込めない
export EXEC_STELPATH=/var/www/html/stellarium
./stellarium/builds/unix/src/stellarium --screenshot-dir $EXEC_STELPATH/screenshots --startup-script $EXEC_STELPATH/scripts/screencapture.ssc --full-screen no

続いて画面からアクセスしたときに実行するindex.php。 phpもhtmlもshellも夢のコラボレーションしてるけど気にしないで。駆け抜けたいんや。

index.php

<?php

// 緯度・経度を受け取る
$lon = $_GET['longitude'];
$lat = $_GET['latitude'];

$script_path = './stellarium/scripts/screencapture.ssc';
$screen_shot_name = md5(uniqid(rand(), true));
$screen_shot_path = "./stellarium/screenshots/{$screen_shot_name}.png";

// stellariumで使用するスクリプトを動的に作成
$script = "
StelMovementMgr.zoomTo(180) ;
core.setObserverLocation({$lon}, {$lat}, 0);
StelMovementMgr.lookEast();
core.wait(0.0);
core.screenshot('{$screen_shot_name}', false, '', true);
core.quitStellarium();
";
$fp = fopen($script_path, 'w');
fwrite($fp, $script);
fclose($fp);

// PHPでスクリプトを実行する際、stdoutとstderrを逃しておかないとプロセスがshellに取られっぱなしになり戻ってこない
exec('sh ./stellarium/exec.sh > /tmp/stellarium-stdout 2>/tmp/stellarium-stderr &');
while(!file_exists($screen_shot_path)) {
    sleep(1);
}

// ファイルが見つかった瞬間に描画すると生成途中の画像が表示され、画像が見切れてしまうため1秒待つ
sleep(1);

// stellariumは一度起動したら起動しっぱなしになるのでスクリーンショットが保存でき次第KILLする
exec("ps -ef | grep stellarium |grep -v grep | awk '{print $2}' | xargs kill");
?>

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title></title>
</head>
<body>
    <img src="<?= $screen_shot_path ?>" alt="hosizora">
</body>
</html>

画面からアクセスしてみる

では実際にhttp://localhost:8080/index.php?longitude=139.1562&latitude=35.2676でアクセスしてみましょう。

f:id:rasukarusan:20181205005242g:plain

やったぜ、、、!!家で星が見える、、、!

本題

これでやっとやりたいことが実現できる環境が整った。ここからはiOSの話です。
とは言ってもアプリ側でやることは非常に少ないです。

  1. 現在の位置情報(緯度・経度)を取得する
  2. 写真を撮る
  3. GET通信でstellariumサーバー(Docker)のURLを叩いて星空の写真を取得
  4. 2で撮った写真と3で取得した星空の写真を合成する

では駆け抜けていきましょう。

アプリ実装

現在位置の取得とカメラ機能の実装コードは特に特筆すべきことはないので割愛します。

GET通信でstellariumサーバー(Docker)のURLを叩いて星空の写真を取得

普通にGETするだけですが、取得した画像をそのまま乗っけると暗かったり色彩が美しく映らないので、諸々調整した画像をUIImageViewに乗せます。 注意するのはGET通信の際のURL指定でしょうか。実機のiPhoneからhttp://localhost:8080..と叩くと失敗するのでMacのIPアドレスを指定するようにしましょう。

import UIKit
import CoreLocation
import AVFoundation

class ViewController: UIViewController, CLLocationManagerDelegate, AVCapturePhotoCaptureDelegate {
    override func viewDidLoad() {
        super.viewDidLoad()
        // 撮影ボタン
        let takeBtn = UIButton()
        takeBtn.frame = CGRect(x:0, y:0, width:self.view.frame.width*0.5, height:self.view.frame.height*0.07)
        takeBtn.center = CGPoint(x:self.view.frame.width/2, y:self.view.frame.height*0.95);
        takeBtn.setTitle("shot", for: .normal)
        takeBtn.backgroundColor = .blue
        takeBtn.addTarget(self, action: #selector(takePhoto(_:)), for: UIControlEvents.touchUpInside)
        self.view.addSubview(takeBtn)
    }

    /*
     * 撮影ボタンを押したらstellariumサーバーにGET通信
     */
    @objc func takePhoto(_ sender: UIButton) {
        getStellariumCapture()
    }

    /*
     * Dockerから星空の画像を取得
     */
    func getStellariumCapture() {
        // 実機のiPhoneからローカル(Docker)のURLを叩くときはlocalhostではなくMacのIPを指定する
        let url = "http://192.168.100.165:8080/index.php?longitude=\(self.nowLon)&latitude=\(self.nowLat)"

        let request = NSMutableURLRequest(url: NSURL(string: url)! as URL)
        request.httpMethod = "GET"
        let task = URLSession.shared.dataTask(with: request as URLRequest, completionHandler: { data, response, error in
            if (error == nil) {
                let result = String(data: data!, encoding: String.Encoding(rawValue: String.Encoding.utf8.rawValue))?.trimmingCharacters(in: .whitespacesAndNewlines)
                let imgURL = URL(string:result!)
                let imgData :Data = try! Data(contentsOf: imgURL!)
                DispatchQueue.main.async {
                    // 明るさ・コンストラクタを調整した画像をセット
                    self.stellarImgView.image = self.createBrightImg(orgImg:UIImage(data: imgData)!)
                }
            } else {
                print(error)
            }
        })
        task.resume()
    }

    /*
     * 撮影した写真の明るさ等を調整する
     */
    func createBrightImg(orgImg : UIImage) -> UIImage {
        let filter = CIFilter(name: "CIColorControls")
        let ciImage = CIImage(image: orgImg)
        filter?.setValue(ciImage, forKey: kCIInputImageKey)
        // 彩度(デフォルト1)
        filter?.setValue(2, forKey: "inputSaturation")
        // 明るさ(0~1, デフォルトは0)
        filter?.setValue(0.6, forKey: "inputBrightness")
        // コントラスト(デフォルト1)
        filter?.setValue(2, forKey: "inputContrast")
        if let filteredImage = filter?.outputImage {
            return UIImage(ciImage: filteredImage)
        }
        return orgImg
    }
}

撮影した写真と星空写真の合成

ここもそんなに難しく考えないで、シンプルに同じ位置にUIImageViewを2枚重ねて合成させる。 星空を乗せるUIImageViewは透過しておきます。

var stellarImgView = UIImageView()
var photoImgView = UIImageView()

override func viewDidLoad() {
    super.viewDidLoad()

    // 撮影した写真を表示するView
    self.photoImgView.frame = CGRect(x:0, y:0, width:self.view.frame.width, height:self.view.frame.height*0.9)
    self.photoImgView.center.x = CGFloat(self.view.frame.width/2)
    view.addSubview(self.photoImgView)

    // 上から重ねる星空画像のView
    self.stellarImgView.frame = CGRect(x:0, y:0, width:self.view.frame.width, height:self.view.frame.height*0.9)
    self.stellarImgView.center.x = CGFloat(self.view.frame.width/2)
    self.stellarImgView.backgroundColor = .clear
    self.stellarImgView.alpha = 0.5
    self.view.addSubview(self.stellarImgView)
}

いざ実行

f:id:rasukarusan:20181205005649g:plain

おおお...!!
とりあえず自分の机で撮ってみたが意外にイケてる...!(若干暗いけど...)

実際に夜空を撮ってみたら下のような感じになりました。結構星っぽくね。

f:id:rasukarusan:20181205010824p:plain

終わり

結構満足してる。
ただ元々SoftwareDesignの記事が超簡単そうだったのでやってみたけど結構時間かかりました。。。
特にseleniumをheadlessにするところやPHPでスクリプトを起動するとプロセス取られっぱなしになったりとかstellariumがどんな環境変数使っているのかとか。実際にやらなかったら「ほーんなるほど簡単そうやな」で終わってた。

でもホントは記事の再現じゃなくてARカメラでリアルタイムに星空を表示したかったんですよね。
ただこれには現状の「毎回stellariumを起動するので画像の取得が超遅い」問題を解決しないといけなかったので断念。
まあとりあえずこれでリリースしてみてもいいかなって思タヨ。

星は結構ネタを盛り込めそうなのでリリースするなら色々実験したいことがあって、

  • 太陽をタップしたと思ったら広告だった
  • 広告を間違えてタップしたと思ったら課金アイテムもらえた
  • 星をズームしたと思ったら孫正義の顔だった
  • 人の顔のパーツをつないで勝手に何座か決める

などなど楽しみがいっぱいある。

星なんて全く興味なかったけど、間に「開発」を挟むと結構何でもおもしろくなることに気づいた。
良い開発ライフだった。