はじめに
この記事は STYLY Advent Calendar 2022 の12/7の記事になります。
現在STYLYの中の人ですが、STYLYが都市用AR機能に対応するにあたって、Geospatial API をさわりましたので、関連して Geospatial API を使って遊んでみた内容の紹介になります。
概要
10年近く以前になりますが、僕は当時セカイカメラというアプリを開発していた中の1人(やや後期)なのですが、セカイカメラでは、カメラ画像に情報をオーバーレイするという方式をとっていました。
参考: https://www.itmedia.co.jp/bizid/articles/0909/30/news113.html
当時、大きな問題の1つと感じていたのは、位置情報の精度になります。GPSを利用しており、精度は通常10m程度でした、そうすると、現地に近づけば近づくほど、オーバーレイされた情報の表示方向が不定になり、カメラ画像の上では、右にいったり左にいったり、後ろにいったりしてしまうのが問題でした。
今であれば、Geospatial APIを利用すれば、条件がよければ1m程度の精度で測位することができますので、カメラにオーバーレイしたときに、大きくずれることは少なくなります。これを狙います。
セカイカメラではユーザーの投稿内容を表示していましたが、今回、表示するロケーション情報は、Google Places APIからレストランや喫茶店の情報を取得することにします。
リポジトリの試し方
リポジトリ
今回作成したコードは以下に置いています。
https://github.com/mhama/GeospatialPlacesSample
Unityバージョン
Unity 2019.4 向け、AndroidまたはiOSビルドが可能です。
Androidビルドの場合:カスタムGradleバージョンの設定
Androidビルドの場合、Unity2019.4で作成した関係で、(ARFoundationとの食い合わせによって)Gradleのバージョンは独自バージョンが必要です。Gradle 6.5.1などを ダウンロード して、PreferenceのExternal Toolsのところで、展開したGradleのフォルダを指定する必要があります。iOSビルドでは不要です。
APIキー
Geospatial用の ARCore API
、および、プレース検索用の Google Places API
を有効化したAPIキーが必要です。
各APIは、以下から有効にできると思います。
※ 注意していただきたい点として、Places API を利用するには、Google Cloudへの課金が必要になります。大量にアクセスを行った場合、それなりの課金額が発生するかもしれません。ご注意ください。
APIキーは、Google API Console の「認証情報」 から作成し、以下の2か所に入力してください。
- Project Settings の ARCore Extensions
- PlaceController.cs の以下の定数
Google Places API (Nearby Search) の課金について
2022/12/07 時点の情報ですが、1000回のリクエストで32ドル等、まあまあの金額がかかることがわかります。今回の実装では1回の検索につき10リクエスト以上投げることがありますので、若干ご注意ください。自分の場合、今のところ、月額$200の無料利用枠におさまっているようで、無料ですんではいます。
実装手法
- ARCore Extention 1.33.0 を利用、Terrain Anchor(地形を高度0とするアンカー)を利用。
- ARCore ExtentionのGeospatialサンプルを改造する
- Google Places APIを利用して近隣のロケーション情報を取得。UniTaskで非同期、並列呼び出し
- ロケーションはTerrainAnchorとして配置する
- uGUIでCanvas内にPanelを使ってロケーションを描画する
- 近くのロケーションのほうを手前に表示するよう、ロケーションとの距離に応じて、Panelの前後関係をソートする
- Geospatialの精度が下がってもロケーション表示が残るようにする
Geospatial APIの基本について
このあたりの記事が参考になると思います。
ARCore Geospatial APIをUnityで使ってみる
【Unity】GeoSpatialAPIの基礎理解~空間共有コンテンツ作成まで
改修ポイント紹介
Google Places APIからのフェッチ:基本
以下のようなコードで、Google Places APIから、近隣のプレース情報をフェッチすることができます。1回の呼び出しで20か所程度しか取得できないことから、ページネーションも実装し、複数回の呼び出しの結果を総合します。
また、一回の呼び出しで一つの type (レストランなど)の情報しか取得できないことから、複数のタイプについて同時アクセスし、UniTask.WhenAllで待ち合わせるようにしてみました。
PlaceItem.cs
/// <summary>
/// Placeを表すValueクラス
/// </summary>
public class PlaceItem
{
public string PlaceUniqueId { get; internal set; } // constructorの初期化子でセットしたいのでinternal setにした
public double Latitude { get; internal set; }
public double Longitude { get; internal set; }
public double Altitude { get; internal set; }
public string Title { get; internal set; }
public float Rating { get; internal set; }
public override string ToString()
{
return $"[PlaceItem] title: {Title} rating: {Rating:F2} location: {Latitude:F5},{Longitude:F5} id: {PlaceUniqueId}";
}
}
PlacesController.cs
/// <summary>
/// Google Place APIを一回だけ呼ぶ
/// 次のページがある場合はreturnタプルの第2要素にnext page tokenを返す。
/// </summary>
/// <param name="latiude"></param>
/// <param name="longiture"></param>
/// <param name="radius"></param>
/// <param name="type"></param>
/// <param name="pageToken"></param>
/// <param name="token"></param>
/// <returns>PlaceItem list, next page token</returns>
async UniTask<(List<PlaceItem>, string)> CallNearbyPlacesApi(double latiude, double longiture, float radius, string type, string pageToken, CancellationToken token)
{
var queries = new Dictionary<string, string>();
queries["location"] = $"{latiude:F6},{longiture:F6}";
queries["language"] = "ja";
queries["radius"] = $"{radius:F1}";
queries["key"] = googlePlacesApiKey;
queries["type"] = type;
var queriesStr = string.Join("&", queries.Select(p => (p.Key + "=" + Uri.EscapeUriString(p.Value))));
var url = googlePlacesApiUrl + queriesStr;
Debug.Log("call google places url: " + url);
using (var uwr = new UnityWebRequest(url))
using (var handler = new DownloadHandlerBuffer())
{
uwr.downloadHandler = handler;
await uwr.SendWebRequest().WithCancellation(token);
if (uwr.isHttpError || uwr.isNetworkError)
{
throw new Exception("api call error. error: " + uwr.error);
}
JObject jobj = JObject.Parse(handler.text);
var places = ConvertJsonToPlaceList(jobj);
Debug.Log("places: " + string.Join("\n", places.Select(p => p.ToString())));
string nextPageToken = jobj["next_page_token"]?.ToString();
return (places, nextPageToken);
}
}
/// <summary>
/// ページネーションつきで近いPlaceを取得する
/// </summary>
/// <param name="latiude"></param>
/// <param name="longiture"></param>
/// <param name="radius"></param>
/// <param name="type"></param>
/// <param name="token"></param>
/// <returns></returns>
async UniTask<List<PlaceItem>> CallNearbyPlacesApiPagenated(double latiude, double longiture, float radius, string type, CancellationToken token)
{
List<PlaceItem> places = new List<PlaceItem>();
string pageToken = null;
int pageCount = 1;
while (pageCount < 10)
{
Debug.Log($"page[{pageCount}] calling CallNearbyPlacesApi({latiude:F4}, {longiture:F4}, {radius:F1}, {type})");
var result = await CallNearbyPlacesApi(latiude, longiture, radius, type, pageToken, token);
if (result.Item2 == null)
{
break;
}
places.AddRange(result.Item1);
pageCount++;
}
return places;
}
/// <summary>
/// レストラン、カフェ、パン屋のプレースを取得してplacesに追加する
/// </summary>
/// <param name="latitude"></param>
/// <param name="longitude"></param>
/// <param name="radius"></param>
/// <param name="token"></param>
/// <returns></returns>
async UniTask<List<PlaceItem>> SearchNearbyPlaces(double latitude, double longitude, float radius, CancellationToken token)
{
var types = new List<string>() {
"restaurant",
"cafe",
"bakery",
};
var tasks = types.Select(type =>
{
return CallNearbyPlacesApiPagenated(latitude, longitude, radius, type, token);
});
var results = await UniTask.WhenAll(tasks);
List<PlaceItem> places = new List<PlaceItem>();
foreach(var result in results)
{
places.AddRange(result);
}
return places;
}
Google Places APIからのフェッチ:疎密への対応
さて、上記のように実装してみて動かしてみたところ、問題がありました。
新宿のようなロケーションが過密なところと、住宅地のようなロケーションが疎なところがあり、取得半径を100mのように固定にしていた場合、新宿では数百件あるが、住宅地では数件、というようになり、どちらにしても適切な量を取得することができません。
そこで、特定の半径で取得してみて、十分な情報(100件の情報)が得られなかった場合は半径を広げるようにしてみます。
「セカイカメラ」においても地域によって情報の疎密の問題があったことが思い起こされます。
PlacesController.cs
async UniTask SearchNearbyPlacesFlexible(double latitude, double longitude, CancellationToken token)
{
float radius = 100;
int totalPlaces = 0;
while(true)
{
var places = await SearchNearbyPlaces(latitude, longitude, radius, token);
Debug.Log($"PlacesController.SearchNearbyPlaces() radius: {radius} places: " + places.Count());
foreach (var place in places)
{
if (!placeAnchorCollection.HasPlace(place.PlaceUniqueId))
{
placeAnchorCollection.AddPlace(place);
}
}
totalPlaces += places.Count;
if (totalPlaces > 100)
{
break;
}
radius *= 3;
}
}
ロケーション情報のPanelの表示
- Unity内の3D座標系から、キャンバス上の位置を求めてPanelを配置します
- カメラから該当ロケーションまでの距離によってPanelのスケール変更。近い距離(30m)だと 2倍、遠い距離(200m)だと 1倍、のようなスケールにします。
/// <summary>
/// プレースを2D表示するパネル
/// Anchorの位置に表示する
/// 距離によってスケールを調整する
/// </summary>
public class PlaceMarkerPanel : MonoBehaviour
{
[SerializeField]
Text titleText;
[SerializeField]
Text distanceText;
[SerializeField]
Text starsText;
[SerializeField]
GameObject rootPanel;
private PlaceItem place;
private Canvas canvas;
public float LastDistance { get; private set; }
/// <summary>
/// このAnchorに合致するスクリーン位置にUIを表示する
/// </summary>
[SerializeField]
private GameObject targetAnchor;
// Start is called before the first frame update
void Start()
{
canvas = transform.GetComponentInParent<Canvas>();
}
private void Update()
{
UpdateScreenPosition();
UpdateText();
float uiScale = CalcUIScale();
rootPanel.transform.localScale = new Vector3(uiScale, uiScale, 1.0f);
}
private void UpdateScreenPosition()
{
if (targetAnchor == null)
{
return;
}
var screenPos = Camera.main.WorldToScreenPoint(targetAnchor.transform.position);
// カメラ(自分)の後ろにあるやつを描画しない
rootPanel.SetActive(screenPos.z > 0);
// screenPosを指定するとPanelの左下端がAnchor位置に合致してしまうので、センターに合致するようオフセットする。
var rectTrans = (transform as RectTransform);
Vector2 offset = new Vector2(rectTrans.rect.width, rectTrans.rect.height) * 0.5f;
(this.transform as RectTransform).localPosition = new Vector3(screenPos.x - offset.x, screenPos.y - offset.y, 0);
}
public void SetPlace(PlaceItem place)
{
this.place = place;
titleText.text = place.Title;
}
private void UpdateText()
{
if (place == null || targetAnchor == null)
{
titleText.text = "Unknown";
distanceText.text = "(-- m)";
starsText.text = "--";
return;
}
LastDistance = DistanceFromCamera();
titleText.text = $"{place?.Title}";
distanceText.text = $"({LastDistance:F0} m)";
starsText.text = $"{place?.Rating:F1}";
}
private float DistanceFromCamera()
{
return (Camera.main.transform.position - targetAnchor.transform.position).magnitude;
}
public void SetTargetAnchor(GameObject targetAnchor)
{
this.targetAnchor = targetAnchor;
}
private const float nearScaleDistance = 30.0f;
private const float farScaleDistance = 200.0f;
/// <summary>
/// UIのスケールを返す
/// LastDistanceが nearScaleDistance より近ければ2.0を返す
/// nearScaleDistance と farScaleDistance の間であれば内分。
/// farScaleDistance より遠ければ1.0を返す
/// 0の場合は例外的に1を返す
/// </summary>
/// <returns></returns>
private float CalcUIScale()
{
if (LastDistance == 0)
{
return 1.0f;
}
float ratio = Mathf.InverseLerp(nearScaleDistance, farScaleDistance, LastDistance);
return Mathf.Lerp(2.0f, 1.0f, ratio);
}
}
ロケーション情報Panelを距離に応じてソート
UIデザインにもよりますが、ロケーションの情報を普通にカメラにオーバーレイした場合、複数のパネルが重なって表示されます。このとき、順序に無頓着でいると、遠くのものが一番手前に見えたりして、うれしくありません。
そこで、近い距離のロケーション情報が手前にくるようにします。Canvas(uGUI)では、子要素の順序によって表示の前後関係が決まってきますので、手前に表示したいものは順序の後のほうになるようソートします。
ですが、ソートはそれなりにコストが高いので、1回のアップデートにつき、バブルソートの「一回し」だけ行うことにしました。しかしながら、重さを計測していませんので、これが妥当な選択かどうかはわかりません。(アロケーションやGetComponentなどのほうが重いかも)
/// <summary>
/// placeMarkerParentの子のPlaceMarkerPanelをソートする
/// バブルソートの1回し分の処理のみ行っており、何度も呼ぶことでソートが完成する
/// </summary>
public void BubbleSortPlaceMarker()
{
List<PlaceMarkerPanel> panels = new List<PlaceMarkerPanel>();
for(int i=0 ; i< placeMarkerParent.childCount ; i++)
{
panels.Add(placeMarkerParent.GetChild(i).GetComponent<PlaceMarkerPanel>());
}
// バブルソートの1回し分の処理
for (int i=0; i< panels.Count-1; i++)
{
var x = panels[i];
var y = panels[i + 1];
if (x.LastDistance < y.LastDistance)
{
y.transform.SetSiblingIndex(i);
}
}
}
階数をvicinity文字列から推測
Google Places APIの情報からは高度がとれません。そこで、Google Places APIの返すvicinityに住所が入るので、この文字列から階数の推測を試みます。末尾に 何階、何F、といった文字列がある場合、階数 x 3m を高度にセットします。
/// <summary>
/// 住所から階数を推定する
/// </summary>
public class FloorFinder
{
readonly Regex regexUnderground = new Regex(@"(地下|B)([\d]+)[F階]($|\s)");
readonly Regex regex = new Regex(@"([\d]+)[F階]($|\s)");
/// <summary>
/// 住所の階数部分をパースする
/// 地下の場合はnullを返す
/// </summary>
/// <param name="address"></param>
/// <returns></returns>
public int? FindFloor(string address)
{
// 地下はnull (1階扱いする)
var resultUnderground = regexUnderground.Match(address);
if (resultUnderground.Success)
{
return null;
}
// 情報がなければnull
var result = regex.Match(address);
if (!result.Success)
{
return null;
}
// 階数文字列部分のキャプチャを取得
// 全角数字もなぜか入ってくる。
var numbersStr = result.Groups[1].Value;
// 全角数字を半角数字にコンバートしてからパースする
var fixedNumberChars = numbersStr.Select(c =>
{
if (c >= '0' && c <= '9')
{
return (char) (c - '0' + '0');
}
return c;
});
var normalizedNumberStr = new string(fixedNumberChars.ToArray());
int value = Int32.Parse(normalizedNumberStr);
return value;
}
}
Geospatialの初期化処理について
もともとサンプルの GeospatialController.cs で行っていたGeospatialの初期化処理ですが、コピーして GeospatialPlacesController.cs で実行しています。
このあたりは、不要な処理も多いので必要な処理のみ残す形で書き直したいところですが、今回は時間切れになります。
まとめ
このような実装を行うことで、カメラ画像にオーバーレイする形で、それなりに快適に近くのレストランなどの情報を入手することができるようになります。
本当はサンプルのUIを外すなど、ちゃんとやりたかったですが、ちょっと時間をとれなかったので、こんな感じでお茶をにごします。
プレース検索を行うと毎回課金が発生することもあり、サンプルGoogleバイナリは用意していませんが、Google Places APIを有効化できる方はぜひ試してみてください。