1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【後編】GTFSを用いた交通情報表示アプリケーションを開発してみた【Unity+ARCore】

Last updated at Posted at 2025-11-13

はじめに

本記事は後編です。前編・中編をご覧になっていない方は先にそちらをご覧になることをお勧めします。

本記事でできること

Andoidスマートフォンで交通情報が表示されるARアプリを作成できる。
OpenStreetMapを用いて経路探索を行い、結果を表示することができる。

目次

実装

経路探索の実装

OpenStreetMapのダウンロード・セットアップ

最後に経路探索を実装していきます。
まずは地図データであるOpenStreetMapをセットアップします。全国のデータを入れてしまうと膨大な量になってしまうため、今回は東京都千代田区、中央区、港区、新宿区、渋谷区に絞りました。以下のURLからKanto regionをダウンロードします。

ダウンロードに成功したら、区の境界を切り出します。以下のURLでコマンドを入力し、境界データを.geojsonでエクスポートしてください。

[out:json][timeout:60];
rel["boundary"="administrative"]["admin_level"="7"]
  ["name"~"^(千代田区|中央区|港区|新宿区|渋谷区)$"];
out geom;

スクリーンショット 2025-11-13 133706.png

注釈
都道府県は"adimn_level"が4、市区町村は7となっています。

エクスポートした境界データは区ごとにポリゴンが分かれているため、1つに結合する必要があります。以下のURLでポリゴン結合を行ってください。

最後に、結合した境界データと地図データから区のみを切り出します。osmium-toolを使用するためPowerShellからUbuntuをダウンロードします。

wsl --install

Ubuntuを選択し、インストール・アカウント作成を行ってください。
Ubuntuのセットアップが完了したら以下のコマンドでアップデートとosmium-toolのインストールを行ってください。

sudo apt update
sudo apt install -y osmium-tool

osmium-toolがインストール出来たら実際に切り出していきます。

osmium extract -p "結合した境界データファイル名" -o osm_wards.pbf -O --set-bounds kanto-latest.osm.pbf

これで切り出しが完了です。

注意
ダウンロードフォルダに各データを入れている場合、Ubuntuが参照するディレクトリが異なり、上手くいかない場合があります。参照しているディレクトリが正しいか確認を行ってください。

OpenTripPlannerのセットアップ

次に経路探索を行うOpenTripPlannerをセットアップしていきます。
まずはOpenTripPlannerを動作させる環境として、Docker Desktopを使用するので、インストールセットアップしてください。

Dockerのセットアップが完了したら、作業フォルダを作成します。Ubuntuで以下のコマンドから作業フォルダを作成・移動し、そこに先ほど切り出さ非た地図データを移してください。

mkdir -p ~/otp-project && cd ~/otp-project

移動が完了したら、経路探索に利用する交通情報をダウンロードします。以下のコマンドでダウンロードしてください。

curl -L -o toei-train.gtfs.zip \
  "https://api-public.odpt.org/api/v4/files/Toei/data/Toei-Train-GTFS.zip"

curl -L -o toeibus.gtfs.zip \
  "https://api-public.odpt.org/api/v4/files/Toei/data/ToeiBus-GTFS.zip"

  export ODPT_TOKEN="APIキー"

  curl -L -o tokyometro.gtfs.zip \
  "https://api.odpt.org/api/v4/files/TokyoMetro/data/TokyoMetro-Train-GTFS.zip?acl:consumerKey=${ODPT_TOKEN}"

ダウンロードが完了したら計算に使用するグラフをビルドします。

docker run -it --rm -p 8080:8080 \
  -e JAVA_TOOL_OPTIONS='-Xmx4g' \
  -v "$(pwd):/var/opentripplanner" \
  opentripplanner/opentripplanner:latest --load --serve

ビルドに成功したらサーバーを起動します。

docker run -it --rm -p 8080:8080 \
  -e JAVA_TOOL_OPTIONS='-Xmx4g' \
  -v "$(pwd):/var/opentripplanner" \
  opentripplanner/opentripplanner:latest --load --serve

ビルド及びサーバ起動成功時には以下の画像のようにOpenTripPlannerの表記が出ます
スクリーンショット 2025-11-12 172130.png

サーバが起動したらテストページが起動できるか試してください。テストページのQueryに以下のコマンドを入力して経路探索が出来ていれば完璧です。

{
  plan(
    from:{ lat:35.681236, lon:139.767125 }  # 東京駅
    to:{   lat:35.658034, lon:139.701636 }  # 渋谷駅
    date:"2025-10-21"
    time:"09:00"
    transportModes:[{mode:WALK},{mode:TRANSIT}]
    numItineraries:5
  ){
    itineraries{
      duration
      legs{
        mode
        from{ name lat lon }
        to{   name lat lon }
        route{ shortName longName }
        legGeometry{ points }
      }
    }
  }
}

スクリーンショット 2025-11-13 134714.png

Unityでの経路探索の実装

最後に、Unity側で経路探索を叩く機構を実装します。
APIを叩くOtpClientとUIとして表示するRouteSearchの2スクリプトを作成しました。

OtpClinetサンプル
c# OtpClient.cs
using System;
using System.Text;
using System.Threading.Tasks;
using Newtonsoft.Json;
using UnityEngine;
using UnityEngine.Networking;

/// <summary>
/// OTP(OpenTripPlanner)のGraphQL APIクライアント
/// 経路検索機能を提供する
/// </summary>
public class OtpClient
{
    readonly string _baseUrl; // 例: http://localhost:8080
    public OtpClient(string baseUrl) => _baseUrl = baseUrl.TrimEnd('/');

    [Serializable] class GraphQlRequest { public string query; }
    [Serializable] class PlanResp { public Data data; }
    [Serializable] public class Data { public Plan plan; }
    [Serializable] public class Plan { public Itinerary[] itineraries; }
    [Serializable]
    public class Itinerary
    {
        public int duration;           // 秒
        public long startTime;         // epoch ms
        public long endTime;           // epoch ms
        public Leg[] legs;
    }
    [Serializable] public class Point { public string name; public double lat; public double lon; }
    [Serializable] public class Route { public string shortName; public string longName; }
    [Serializable]
    public class Leg
    {
        public string mode;
        public long startTime;         // epoch ms
        public long endTime;           // epoch ms
        public Point from; public Point to;
        public Route route;
    }

    /// <summary>
    /// 経路検索を実行する
    /// </summary>
    public async Task<Plan> PlanAsync(
        Vector2 fromLonLat, Vector2 toLonLat,
        DateTime? whenLocal = null, bool arriveBy = false,
        int numItineraries = 5, int? searchWindowSec = null)
    {
        var when = whenLocal ?? DateTime.Now;              // ローカル時刻(JST)
        string dateISO = when.ToString("yyyy-MM-dd");
        string timeHM = when.ToString("HH:mm");
        string sw = searchWindowSec.HasValue ? $", searchWindow:{searchWindowSec.Value}" : "";
        string ab = arriveBy ? ", arriveBy:true" : "";

        // Transmodel GraphQL
        string q = $@"
{{
  plan(
    from:{{ lat:{fromLonLat.y}, lon:{fromLonLat.x} }}
    to:  {{ lat:{toLonLat.y},   lon:{toLonLat.x} }}
    date:""{dateISO}""
    time:""{timeHM}""
    transportModes:[{{mode:WALK}},{{mode:TRANSIT}}]
    numItineraries:{numItineraries}{sw}{ab}
  ){{
    itineraries{{
      duration startTime endTime
      legs{{
        mode startTime endTime
        from{{ name lat lon }} to{{ name lat lon }}
        route{{ shortName longName }}
      }}
    }}
  }}
}}";

        var body = new GraphQlRequest { query = q };
        var json = JsonConvert.SerializeObject(body);
        using var req = new UnityWebRequest($"{_baseUrl}/graphql", "POST");
        req.uploadHandler = new UploadHandlerRaw(Encoding.UTF8.GetBytes(json));
        req.downloadHandler = new DownloadHandlerBuffer();
        req.SetRequestHeader("Content-Type", "application/json");
        var op = req.SendWebRequest();
        while (!op.isDone) await Task.Yield();
        if (req.result != UnityWebRequest.Result.Success)
            throw new Exception($"OTP plan failed: {req.error}\n{req.downloadHandler.text}");

        var resp = JsonConvert.DeserializeObject<PlanResp>(req.downloadHandler.text);
        return resp.data.plan;
    }

    // 表示用ヘルパ
    /// <summary>
    /// エポックミリ秒をHH:mm形式の文字列に変換する
    /// </summary>
    public static string Hm(long epochMs) =>
        DateTimeOffset.FromUnixTimeMilliseconds(epochMs).ToLocalTime().ToString("HH:mm");
    /// <summary>
    /// 秒数を時間・分のラベルに変換する
    /// </summary>
    public static string DurLabel(int seconds)
    {
        var ts = TimeSpan.FromSeconds(seconds);
        return ts.TotalHours >= 1 ? $"{(int)ts.TotalHours}時間{ts.Minutes}分" : $"{ts.Minutes}分";
    }
    /// <summary>
    /// 区間の路線名ラベルを取得する
    /// </summary>
    public static string LineLabel(Leg leg) =>
        leg.mode == "WALK" ? "徒歩" :
        (leg.route?.shortName ?? leg.route?.longName ?? "Transit");
}

RouteSearchサンプル
c# RouteSearch.cs
using System.Collections;
using System.Text;
using UnityEngine;
using UnityEngine.Networking;
using Newtonsoft.Json;

/// <summary>
/// OTP(OpenTripPlanner)のGraphQL APIを使用して経路検索を行うUIコンポーネント
/// </summary>
public class RouteSearch : MonoBehaviour
{
    [Header("OTP(サーバーアドレス・エンドポイントまで)")]
    [Tooltip("OTPサーバーのベースURL(例: http://127.0.0.1:8080、adb reverse使用時)")]
    public string otpBaseUrl = "http://127.0.0.1:8080";
    private readonly string graphqlPath = "/otp/gtfs/v1"; // GTFS GraphQL のエンドポイント(POST用)

    [Header("座標 (lon,lat)")]
    [Tooltip("出発地点の経度・緯度")]
    public Vector2 fromLonLat = new Vector2(139.767125f, 35.681236f); // 東京駅
    [Tooltip("到着地点の経度・緯度")]
    public Vector2 toLonLat = new Vector2(139.701636f, 35.658034f); // 渋谷駅

    [Header("取得/表示オプション")]
    [Tooltip("徒歩区間も表示する場合true")]
    public bool showWalkLegs = false;
    [Tooltip("最大1本のみ取得するなら1")]
    public int numItineraries = 1;
    [Tooltip("検索ウィンドウ(秒)")]
    public int searchWindowSec = 5400; // 90分
    [Tooltip("表示テキストのサイズ")]
    public int fontSize = 28;

    private string _text = "Loading…";

    // ---- DTO(必要最小限)----
    class GReq { public string query; }
    class Resp { public Data data; public object errors; }
    class Data { public Plan plan; }
    class Plan { public Itin[] itineraries; }
    class Itin { public long startTime; public long endTime; public int duration; public Leg[] legs; }
    class Leg { public string mode; public long startTime; public long endTime; public Pt from; public Pt to; public Rt route; }
    class Pt { public string name; }
    class Rt { public string shortName; public string longName; }

    void Start()
    {
        StartCoroutine(FetchAndRender());
    }

    /// <summary>
    /// OTP APIから経路情報を取得して表示するコルーチン
    /// </summary>
    IEnumerator FetchAndRender()
    {
        _text = "経路検索中…";

        string dateISO = System.DateTime.Now.ToString("yyyy-MM-dd");
        string timeHM = System.DateTime.Now.ToString("HH:mm");

        // GraphQL クエリ(GTFS flavor の plan)
        string q = $@"
{{
  plan(
    from:{{lat:{fromLonLat.y}, lon:{fromLonLat.x}}}
    to:  {{lat:{toLonLat.y},   lon:{toLonLat.x}}}
    date:""{dateISO}"" time:""{timeHM}""
    transportModes:[{{mode:WALK}},{{mode:TRANSIT}}]
    numItineraries:{numItineraries}
    searchWindow:{searchWindowSec}
  ){{
    itineraries{{
      duration startTime endTime
      legs{{
        mode startTime endTime
        from{{name}} to{{name}}
        route{{ shortName longName }}
      }}
    }}
  }}
}}";

        var bodyJson = JsonConvert.SerializeObject(new GReq { query = q });
        var url = otpBaseUrl.TrimEnd('/') + graphqlPath;

        using (var req = new UnityWebRequest(url, "POST"))
        {
            req.uploadHandler = new UploadHandlerRaw(Encoding.UTF8.GetBytes(bodyJson));
            req.downloadHandler = new DownloadHandlerBuffer();
            req.SetRequestHeader("Content-Type", "application/json");

            var op = req.SendWebRequest();
            while (!op.isDone) yield return null;

            if (req.result != UnityWebRequest.Result.Success || (int)req.responseCode != 200)
            {
                _text = $"HTTPエラー: {req.responseCode} / {req.error}\nURL: {req.url}";
                yield break;
            }

            Resp r;
            try
            {
                r = JsonConvert.DeserializeObject<Resp>(req.downloadHandler.text);
            }
            catch (System.Exception ex)
            {
                _text = "JSONパース失敗:\n" + ex.Message;
                yield break;
            }

            if (r?.data?.plan?.itineraries == null || r.data.plan.itineraries.Length == 0)
            {
                _text = "経路が見つかりませんでした(座標を変更 / searchWindow を延長 など)。";
                yield break;
            }

            var it = r.data.plan.itineraries[0];

            var sb = new StringBuilder();
            // 東京駅→渋谷駅:開始時刻と終了時刻と所要時間
            sb.AppendLine($"[{Hm(it.startTime)}{Hm(it.endTime)}  所要{Minutes(it.startTime, it.endTime)}分]");

            // 各区間:出発駅の次に所要時間(分)と到着時刻
            foreach (var leg in it.legs)
            {
                if (!showWalkLegs && leg.mode == "WALK") continue;

                string lineName = leg.route?.shortName
                                  ?? leg.route?.longName
                                  ?? (leg.mode == "WALK" ? "徒歩" : "Transit");

                int legMin = Minutes(leg.startTime, leg.endTime);
                sb.AppendLine($"{leg.from?.name}{leg.to?.name}{lineName}{legMin}分、{Hm(leg.endTime)}着)");
            }

            _text = sb.ToString().TrimEnd();
        }
    }

    // ---- 表示 ----
    void OnGUI()
    {
        var st = new GUIStyle(GUI.skin.label) { fontSize = fontSize, normal = { textColor = Color.white } };
        GUILayout.BeginArea(new Rect(24, 24, Screen.width - 48, Screen.height - 48));
        GUILayout.BeginVertical("box");
        GUILayout.Label(_text, st);
        GUILayout.EndVertical();
        GUILayout.EndArea();
    }

    // ---- 時刻/時間変換ヘルパ ----
    /// <summary>
    /// エポック秒またはミリ秒をミリ秒に統一する
    /// </summary>
    static long ToMs(long t) => t < 1_000_000_000_000L ? t * 1000L : t; // 秒/ミリ秒どちらでも対応
    /// <summary>
    /// エポック時刻をローカル時刻のDateTimeに変換する
    /// </summary>
    static System.DateTime Local(long tMsOrSec) =>
        System.DateTimeOffset.FromUnixTimeMilliseconds(ToMs(tMsOrSec)).LocalDateTime;
    /// <summary>
    /// エポック時刻をHH:mm形式の文字列に変換する
    /// </summary>
    static string Hm(long tMsOrSec) => Local(tMsOrSec).ToString("HH:mm");
    /// <summary>
    /// 開始時刻と終了時刻の差を分単位で計算する
    /// </summary>
    static int Minutes(long start, long end) =>
        (int)System.Math.Max(0, (ToMs(end) - ToMs(start)) / 1000L / 60L);
}

ここまで出来たらRouteSearchをアタッチしてください。
スクリーンショット 2025-11-12 174145.png

このままではサーバを叩く権限がないので、PowerShellから以下のコマンドを入力してサーバを叩けるようにしてください。

adb devices
adb reverse tcp:8080 tcp:8080 
adb reverse --list

この工程を忘れるとこのように画面上部のUIがHTTPエラーを表示して、経路探索が行えません。

注釈
adb devicesはサーバに接続されている端末表示
adb reverse tcp:8080 tcp:8080は端末からPC内のサーバにアクセスするトンネル作成を行っています。
USB接続を切断したら毎回行ってください。

画像のように端末名とtcp:8080がリストになっていれば完了です
スクリーンショット 2025-11-12 174500.png

テスト

テストを行うと画面上部に東京から渋谷への経路探索の結果がUIで表示されます。

最終的に、DebugTextを非表示にし、UIを表示させるとこのようになります。
Videotogif (3).gif

まとめ

これにて、全3回に渡るAR交通アプリの開発が終了しました。
当初は、何分遅延などのリアルタイムな情報や地下鉄以外のバスなどの表示と幅広く行いたかったのですが、技術力が足りず中途半端な実装状態となってしまいました。
一方で、アイコンの切り替えや上下線の矢印表記、最寄りのみに絞るなど表示させる内容やデータの軽量化は工夫できたと思います。
今回のMVPで終了せず、事業者を増やしたり精度を上げた拡張版を制作していきたいと思うので、そちらが完成した際には記事にしていこうと思います。よろしければ是非ご覧ください。

参考

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?