見出し画像

Cesiumでジオラマアプリを作ってみよう! 第3回「API連携編」(全4回)

はじめに

第3回「API連携編」では、Google Maps PlatformのPlaces APIOpenAI APIを活用したジオコーディングを紹介したいと思います。APIはどちらも事前にアカウントを作成してAPIキーを発行しておく必要があります。無料枠には限界がありますので課金額を確認しておいてください。

第3回「API連携編」目次


付近の施設情報を取得する

第2回「座標編」でやったように、クリックした箇所の緯度経度が取得できるため、
その緯度経度をGoogleのNearby Searchに渡して周辺情報を取得しています。

Google Nearby Search

Nearby Searchは緯度経度を渡して、周辺の施設情報を取得できるAPIです。
ユーザーレビューや営業時間等の欲しい情報の指定ができるため、やろうと思えばGoogleMapのように実装することも可能です。(※欲しい情報によって課金額が変わるので注意が必要です)
今回は簡単に住所、カテゴリー、写真を取得してピンに表示してみたいと思います。
写真の取得には次項のPlace Photoがさらに必要になります。

/// <summary>
/// NearBySearchAPI
/// </summary>
private const string NEARBY_SEARCH_API = "https://places.googleapis.com/v1/places:searchNearby";

/// <summary>
/// 付近の検索API(Google ※無料枠は$200/月 約6000回)
/// </summary>
public static async UniTask<ResponseBody> RequestNearBySearchAsync(double latitude, double longitude, int resultCount, float radius, CancellationToken cancellationToken = default)
{
    var requestBody = new RequestBodyNearBySearch
    {
        maxResultCount = resultCount,
        locationRestriction = new LocationRestriction(latitude,longitude,radius)
    };

    //取得する情報の指定("*"で全指定できる)
    var fieldValues = new string[]
    {
        "places.displayName", 
        "places.shortFormattedAddress",
        "places.formattedAddress", 
        "places.primaryTypeDisplayName",
        "places.location",
        "places.photos"
    };
    
    Debug.Log($"リクエスト:NearBySearchAPI");
    var result = await postRequestAsync(NEARBY_SEARCH_API, requestBody, fieldValues, cancellationToken);
    Debug.Log($"NearBySearchAPI 完了 : {result}");
    return JsonUtility.FromJson<ResponseBody>(result);
}

/// <summary>
/// WebRequest(POST)
/// </summary>
/// <param name="url"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
private static async UniTask<string> postRequestAsync<TRequestBody>(string url,TRequestBody requestBody, string[] fieldValues ,CancellationToken cancellationToken)
{
    using var request = new UnityWebRequest(url, UnityWebRequest.kHttpVerbPOST);
    byte[] bodyRaw = JsonTools.SerializeToUTF8(requestBody);
    request.uploadHandler = new UploadHandlerRaw(bodyRaw);
    request.downloadHandler = new DownloadHandlerBuffer();
    request.SetRequestHeader("Content-Type", "application/json");
    request.SetRequestHeader("X-Goog-Api-Key", API_KEY);
    var fieldMasksSb = new StringBuilder();
    fieldMasksSb.AppendJoin(",",fieldValues);
    request.SetRequestHeader("X-Goog-FieldMask", fieldMasksSb.ToString());
    
    await request.SendWebRequest().WithCancellation(cancellationToken);
    var result = request.downloadHandler.text;
    return result;
}

Google Place Photo

Place Photoは施設の画像を取得できるAPIです。
先にNearBySearchやTextSearch等の他のAPIで事前に画像リソース名を取得しておく必要があります。
それらのレスポンスのphotos.authorAttributions.photoUriでも画像が取得できますが、これは投稿者のアイコン画像であるため注意してください。

/// <summary>
/// Place Photo API
/// </summary>
/// <param name="name">画像のリソース名(他APIのレスポンスのname)</param>
/// <param name="maxHeightPx">最大の縦幅</param>
/// <param name="maxWidthPx">最大の横幅</param>
/// <param name="ct"></param>
/// <returns></returns>
public static async UniTask<ResponsePhoto> RequestPlacePhoto(string name, int maxHeightPx, int maxWidthPx, CancellationToken ct)
{
    var args = new Dictionary<string, string>()
    {
        {"key",$"{API_KEY}"},
        {"maxHeightPx",$"{maxHeightPx}"},
        {"maxWidthPx",$"{maxWidthPx}"},
        {"skipHttpRedirect","true"}
    };
    var url = $"https://places.googleapis.com/v1/{name}/media";
    
    Debug.Log("リクエスト:PlacePhotoAPI");
    var result = await getRequestAsync(url,args,ct);
    Debug.Log($"PlacePhotoAPI:完了:{result}");
    return JsonTools.Deserialize<ResponsePhoto>(result);
}

/// <summary>
/// WebRequest(GET)
/// </summary>
/// <param name="url"></param>
/// <param name="aug"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
private static async UniTask<string> getRequestAsync(string url, Dictionary<string,string> aug, CancellationToken cancellationToken)
{
    bool isFirst = true;
    foreach (var keyAndValue in aug)
    {
        url += isFirst ? "?" : "&";
        if (isFirst) isFirst = false;
        url += $"{keyAndValue.Key}={keyAndValue.Value}";
    }
    
    using var request = new UnityWebRequest(url, UnityWebRequest.kHttpVerbGET)
    {
        downloadHandler = new DownloadHandlerBuffer()
    };
    await request.SendWebRequest().WithCancellation(cancellationToken);
    var result = request.downloadHandler.text;
    return result;
}

Raycastした周辺情報の表示

これらのAPIと第2回「座標編」で行ったRaycastを組み合わせて、クリックした所にピンを立てて、
周辺情報を表示してみたいと思います。ピンは任意で適当に作成してください。
Raycastした時に取得できる緯度経度をAPIに渡して、その結果をピンに表示しています。

/// <summary>
/// Rayが当たった時
/// </summary>
/// <param name="hitPosition"></param>
private void onRayCastTiles(Vector3 hitPosition)
{    
    _rayAnchor.transform.position = hitPosition;    
    _rayAnchor.Sync();    
    var latitude = _rayAnchor.longitudeLatitudeHeight.y;    
    var longitude = _rayAnchor.longitudeLatitudeHeight.x;    
    Debug.Log($"Hit: 座標:{hitPosition} : 緯度:{latitude},経度:{longitude}");
    
    //Rayの当たった場所にPinを生成    
    var pinObj = Instantiate(_pinPrefab,hitPosition,Quaternion.identity,_pinParentT);    
    var pinRenderer = pinObj.GetComponent<PinRenderer>();    
    //Pinに付近の施設情報の表示    
    nearbyInfoAsync(pinRenderer).Forget();
}

/// <summary>
/// ピンに周辺情報の表示
/// </summary>
/// <param name="pin"></param>
private async UniTask nearbyInfoAsync(PinRenderer pin)
{
    const float radius = 100f;
    const int resultCount = 10;
    var longitudeLatitudeHeight = pin.SyncGeographicPosition();
    double2 geoPos = new double2(longitudeLatitudeHeight.x, longitudeLatitudeHeight.y);
    string errorText = null;
    var resultList = new List<Place>();
    try
    {
        var response = await GoogleAPI.RequestNearBySearchAsync(geoPos.y,geoPos.x, resultCount,radius, _ct);
        resultList = response.places;
    }
    catch (Exception e)
    {
        errorText = $"APIエラー : {e}";
        Debug.Log(errorText);
        pin.SetErrorText(errorText,Color.red);
        return;
    }
    if (resultList.Count == 0)
    {
        pin.SetErrorText("周辺情報がありませんでした",Color.yellow);
        return;
    }
    foreach (var result in resultList)
    {
        if (!string.IsNullOrEmpty(result.displayName.text))
        {
            //ピンの中のセルの作成
            var cell = pin.CreateInfoCell(result);
            if (result.photos.Count > 0)
            {
                var photoName = result.photos[0].name;
                loadCellThumbnailAsync(cell,photoName).Forget();
            }
        }
    }
}

/// <summary>
/// セルのサムネイル画像を読み込んで表示
/// </summary>
/// <param name="cell"></param>
/// <param name="photoName"></param>
private async UniTaskVoid loadCellThumbnailAsync(SearchDataCell cell,string photoName)
{
    const int maxHeightPx = 256;
    const int maxWidthPx  = 144;
    var response = await GoogleAPI.RequestPlacePhoto(photoName, maxHeightPx, maxWidthPx, _ct);
    var imageUrl = response.photoUri;
    if (!string.IsNullOrEmpty(imageUrl))
    {
        using var request = UnityWebRequestTexture.GetTexture(imageUrl);
        await request.SendWebRequest().WithCancellation(_ct);
        if (request.result != UnityWebRequest.Result.Success)
        {
            Debug.Log($"サムネイル取得失敗:{request.error}");
            return;
        }
        var texture = DownloadHandlerTexture.GetContent(request);
        cell.SetThumbnail(texture);
    }
}

ピン側のスクリプトのサンプル

/// <summary>
/// 施設情報を表示するセルの作成
/// </summary>
/// <param name="result"></param>
public SearchDataCell CreateInfoCell(Place result)
{
    _stateText.gameObject.SetActive(false);
    var go = Instantiate(_cellPrefab, _prefabParentT);
    var cell = go.GetComponent<SearchDataCell>();
    var tName = truncateWithEllipsis(result.displayName?.text, 15);
    var resultCategory = "None";
    if (result.primaryTypeDisplayName != null)
    {
        if (!string.IsNullOrEmpty(result.primaryTypeDisplayName.text))
        {
            resultCategory = result.primaryTypeDisplayName.text;
        }   
    }
    var tCategory = truncateWithEllipsis(resultCategory, 13);
    var tAddress = truncateWithEllipsis(result.shortFormattedAddress, 13);
    cell.SetName($"{tName}");
    cell.SetCategory($"{tCategory}");
    cell.SetAddress($"{tAddress}");
    return cell;
}

/// <summary>
/// エラーテキストの設定
/// </summary>
/// <param name="errorText"></param>
/// <param name="color"></param>
public void SetErrorText(string errorText , Color color)
{
    _stateText.color = color;
    _stateText.text = errorText;
}

/// <summary>
/// 文字列を指定数に丸め込む
/// </summary>
/// <param name="value"></param>
/// <param name="maxChars"></param>
/// <returns></returns>
private string truncateWithEllipsis(string value, int maxChars)
{
    if (string.IsNullOrEmpty(value)) return value;
    return value.Length <= maxChars ? value : value.Substring(0, maxChars) + "...";
}

これで以下のような感じで周辺情報が表示されるようになったかと思います。

★クリックして動画を再生★

音声で任意の場所に移動する

話した言葉をChatGPT(OpenAI)が提供しているAPIのFunction Callingという機能を使って形態素解析をやってもらい、その結果をGoogleのText Searchに渡して緯度経度を取得し、Cesium上で任意の場所に移動します。Function Callingのみでも緯度経度を取得可能ですが、大きな座標のずれが発生してあまり実用的ではないため、Googleにさらに渡しています。

Windows Speech DictationRecognizer

今回はWindowsで音声認識を行うためDictationRecognizerを利用したいと思います。
Unityに内蔵されているため、別途何かしらのインストールは不要です。
MacやiPhone,Quest等で動かしたい場合は別途用意する必要があります。
使い方は簡単で、コールバック登録して開始するのみです。

using UnityEngine.Windows.Speech;
DictationRecognizer dictationRecognizer = new DictationRecognizer();
//コールバック登録
dictationRecognizer.DictationResult += onDictationResult;
dictationRecognizer.DictationComplete += onDictationComplete;
dictationRecognizer.DictationError += onDictationError;
//音声認識開始
dictationRecognizer.Start();

ChatGPT(OpenAI) Function calling

UnityではChatGPT-API-unityというのを作成してくれている方がいるのでこれを利用します。
以下は主語を抜き出す時のFunction callingのサンプルです。descriptionを具体的に書いたり、例を載せてあげると成功率が上がります。

/// <summary>
/// チャットから主語を抜き出す。
/// </summary>
/// <param name="message"></param>
/// <param name="model"></param>
/// <param name="ct"></param>
/// <returns></returns>
public async UniTask<ResponseKeyword> RequestKeywordAsync(string message, Model model ,CancellationToken ct)
{
    const string funcName = "getKeyword";
    var function = new Function
    (
        name: funcName,
        description: "主語の取得",
        parameters: new Dictionary<string, object>
        {
            { "type", "object" },
            {
                "properties", new Dictionary<string, object>
                {
                    {
                        "keyword", new Dictionary<string, object>
                        {
                            { "type", "string" },
                            {  "desciption" , "主語 例:東京の都庁、東京駅、レストラン、コンビニ、ラーメン、ホテル、病院" }
                        }
                    }
                }
            },
            { "required", new List<string> {"keyword"} },
        }
    );
    
    var responseStr = await completeChatFuncAsync(message, funcName, function, model, ct);
    return JsonConvert.DeserializeObject<ResponseKeyword>(responseStr);
}

/// <summary>
/// CompletionsAPI(Function Calling)を叩く
/// </summary>
/// <param name="message"></param>
/// <param name="funcName"></param>
/// <param name="function"></param>
/// <param name="model"></param>
/// <param name="ct"></param>
/// <returns></returns>
private async UniTask<string> completeChatFuncAsync(string message, string funcName , Function function, Model model , CancellationToken ct)
{
    if (_connection == null)
    {
        Debug.LogError($"InitChat()を先に呼び出してください。");
        return null;
    }
    
    ChatCompletionResponseBody response;
    try
    {
        await UniTask.SwitchToThreadPool();
        
        response = await _connection
            .CompleteChatAsync(
                message,
                ct,
                model: model,
                functions: new List<Function> { function },
                functionCallSpecifying: new FunctionCallSpecifying(name: funcName)
            );    
    }
    catch (Exception e)
    {
        Debug.LogError(e);
        if (e.InnerException.Message.Contains("Too Many Requests"))
        {
            throw new Exception("OpenAIの利用制限を超えています。");
        }
        return null;
    }
    finally
    {
        await UniTask.SwitchToMainThread(ct);   
    }

    return response.Choices[0].Message.FunctionCall.Arguments;
}

[Serializable]
public class ResponseKeyword
{
    public string keyword;
}

ちなみにおススメしませんが、緯度経度を取得したい場合は以下のようにできます。
Function callingは要求するデータが多くなるほど、レスポンスが遅くなるので注意が必要です。

/// <summary>
/// チャットでジオコーディングのリクエスト
/// </summary>
/// <param name="message"></param>
/// <param name="model"></param>
/// <param name="ct"></param>
/// <returns></returns>
public async UniTask<ResponseGeocoding> RequestGeocodingAsync(string message, Model model ,CancellationToken ct)
{
const string funcName = "geocoding";

var function = new Function
(
    name: funcName,
    description: "入力された場所そのまま(input)と指定された場所の名称(placeName)、緯度経度情報(latitude,longitude)を取得します。",
    parameters: new Dictionary<string, object>
    {
        { "type", "object" },
        {
            "properties", new Dictionary<string, object>
            {
                {
                    "placeName", new Dictionary<string, object>
                    {
                        { "type", "string" },
                        {  "desciption" , "日本語表記で指定された場所 例:東京駅" }
                    }
                },
                {
                    "yomi", new Dictionary<string, object>
                    {
                        { "type", "string" },
                        {  "desciption" , "指定された場所の読み方 例:とうきょう えき" }
                    }
                },
                {
                    "input", new Dictionary<string, object>
                    {
                        { "type", "string" },
                        {  "desciption" , "入力文字列をそのまま記入 例:日本の首都" }
                    }
                },
                {
                    "address", new Dictionary<string, object>
                    {
                        { "type", "string" },
                        {  "desciption" , "指定された場所の住所 例:〒163-8001 東京都新宿区西新宿2丁目8-1" }
                    }
                },
                {
                    "latitude", new Dictionary<string, object>
                    {
                        { "type", "number" },
                        {  "desciption" , "Geographical WGS84 coordinate of the location" }
                    }
                },
                {
                    "longitude", new Dictionary<string, object>
                    {
                        { "type", "number" },
                        {  "desciption" , "Geographical WGS84 coordinate of the location" }
                    }
                }
            }
        },
        { "required", new List<string> {"placeName","yomi","input","address","latitude","longitude"} },
    }
);
var responseStr = await completeChatFuncAsync(message, funcName, function, model, ct);
return JsonConvert.DeserializeObject<ResponseGeocoding>(responseStr);
}

[Serializable]
public class ResponseGeocoding
{
    public string placeName;
    public string yomi;
    public string input;
    public string address;
    public double latitude;
    public double longitude;

    public override string ToString()
    {
        var stb = new StringBuilder($"地名:{placeName},読み:{yomi}");
        stb.AppendLine("");
        stb.AppendLine($"入力文字列:{input}");
        stb.AppendLine($"住所:{address}");
        stb.AppendLine($"緯度:{latitude}");
        stb.AppendLine($"経度:{longitude}");
        return stb.ToString();
    }

    public bool IsEmpty()
    {
        return string.IsNullOrEmpty(placeName) || longitude == 0 || latitude == 0;
    }
}

Google Text Search

Text Searchは住所や施設名等の文字列を渡して緯度経度を取得できるAPIです。
使い方はNearbySearchとほとんど同じです。

/// <summary>
/// TextSearchAPI
/// </summary>
private const string TEXT_SEARCH_API = "https://places.googleapis.com/v1/places:searchText";

/// <summary>
/// テキスト検索API(Google ※無料枠は$200/月 約6000回)
/// </summary>
/// <param name="placeName"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
public static async UniTask<ResponseBody> RequestTextSearchAsync(string placeName,int resultCount, LocationRestriction locationBias = null,CancellationToken cancellationToken = default)
{
    var requestBody = new RequestBodyTextSearch
    {
        textQuery = placeName,
        maxResultCount = resultCount,
        locationBias = locationBias
    };

    //取得する情報の指定("*"で全指定できる)
    var fieldValues = new string[]
    {
        "places.displayName", 
        "places.shortFormattedAddress",
        "places.formattedAddress", 
        "places.primaryTypeDisplayName",
        "places.location",
        "places.photos"
    };
    
    Debug.Log("リクエスト:TextSearchAPI");
    var result = await postRequestAsync(TEXT_SEARCH_API,requestBody,fieldValues,cancellationToken);
    Debug.Log($"TextSearchAPI 完了 : {result}");
    return JsonTools.Deserialize<ResponseBody>(result);
}

音声入力で移動してみる

以上を連携して音声入力をすると以下のようなスクリプトになります。
MicCanvasはボタン押下して認識開始するためのUIなので適当に作成してください。

/// <summary>
/// 音声認識で地理座標の移動
/// </summary>
[RequireComponent(typeof(CesiumGeoreference))]
public class VoiceGeocoder : MonoBehaviour
{
    /// <summary>
    /// マイクのUI
    /// </summary>
    [SerializeField] 
    private MicCanvas _micCanvas;
    
    /// <summary>
    /// CesiumGeoreference
    /// </summary>
    private CesiumGeoreference _cesiumGeoreference;
    
    /// <summary>
    /// OpenAI
    /// </summary>
    private OpenAiService     _openAiService;
    
    /// <summary>
    /// Windowsの音声認識
    /// </summary>
    private WinVoiceRecognizer  _winVoiceRecognizer;

    /// <summary>
    /// キャンセルトークン
    /// </summary>
    private CancellationToken _ct;
    
    /// <summary>
    /// 音声認識のタイムアウト
    /// </summary>
    private const float TIME_OUT = 15f;
    
    /// <summary>
    /// OpenAIのAPIキー
    /// </summary>
    private const string OPEN_AI_API_KEY = "YOUR_KEY";
    
    void Start()
    {
        _cesiumGeoreference = GetComponent<CesiumGeoreference>();

        _ct = destroyCancellationToken;
        _openAiService = new OpenAiService(OPEN_AI_API_KEY);
        _winVoiceRecognizer = new WinVoiceRecognizer(TIME_OUT);
        _micCanvas.OnClickStartObservable
                    .Subscribe(_ => geocodeAsync().Forget())
                    .AddTo(this);
    }

    /// <summary>
    /// 音声入力で地理座標の移動
    /// </summary>
    private async UniTaskVoid geocodeAsync()
    {
        const int resultCount = 1;
        const int errorTime = 3;
        
        try
        {
            _micCanvas.EnableWaiting(true);
            //音声認識開始
            var recognizeStr = await _winVoiceRecognizer.StartReconizeAsync(_ct);
            _micCanvas.SetText($"認識結果:{recognizeStr}");
            _winVoiceRecognizer.StopReconize();
            _openAiService.InitChat();
            //認識結果をOpenAIに渡して主語の取得
            var responseKeyword = await _openAiService.RequestKeywordAsync(recognizeStr, Model.Four, _ct);
            var targetStr = responseKeyword.keyword;
            _micCanvas.SetText($"移動先:{targetStr}");
            _openAiService.ClearChatMemory();
            //Googleに主語を渡して緯度経度の取得
            var responseBody = await GoogleAPI.RequestTextSearchAsync(targetStr, resultCount, cancellationToken: _ct);
            var location = responseBody.places[0].location;
            _cesiumGeoreference.latitude = location.latitude;
            _cesiumGeoreference.longitude = location.longitude;
        }
        catch (TimeoutException e)
        {
            _micCanvas.SetText("音声認識タイムアウト");
            await UniTask.Delay(TimeSpan.FromSeconds(errorTime),cancellationToken:_ct);
        }
        catch (Exception e)
        {
            _micCanvas.SetText($"エラー:{e.Message}");
            await UniTask.Delay(TimeSpan.FromSeconds(errorTime),cancellationToken:_ct);
        }
        finally
        {
            _micCanvas.EnableWaiting(false);   
        }
    }
}//VoiceGeocoder End

これで以下の様に任意の場所へ移動できるようになったかと思います。

★クリックして動画を再生★

スクリプト全容

GoogleAPI

GoogleAPIを利用するクラスの全容です。

/// <summary>
/// Google API
/// </summary>
public static class GoogleAPI
{
    #region Variables
    /// <summary>
    /// GoogleAPI Key
    /// </summary>
    private const string API_KEY = "YOUR_KEY";
    
    /// <summary>
    /// NearBySearchAPI
    /// </summary>
    private const string NEARBY_SEARCH_API = "https://places.googleapis.com/v1/places:searchNearby";
    
    /// <summary>
    /// TextSearchAPI
    /// </summary>
    private const string TEXT_SEARCH_API = "https://places.googleapis.com/v1/places:searchText";
    #endregion
    
    #region PublicMethod
    /// <summary>
    /// 付近の検索API(Google ※無料枠は$200/月 約6000回)
    /// ドキュメント:https://developers.google.com/maps/documentation/places/web-service/nearby-search?hl=ja
    /// </summary>
    public static async UniTask<ResponseBody> RequestNearBySearchAsync(double latitude, double longitude, int resultCount, float radius, CancellationToken cancellationToken = default)
    {
        var requestBody = new RequestBodyNearBySearch
        {
            maxResultCount = resultCount,
            locationRestriction = new LocationRestriction(latitude,longitude,radius)
        };

        //取得する情報の指定("*"で全指定できる。)
        var fieldValues = new string[]
        {
            "places.displayName", 
            "places.shortFormattedAddress",
            "places.formattedAddress", 
            "places.primaryTypeDisplayName",
            "places.location",
            "places.photos"
        };
        
        Debug.Log($"リクエスト:NearBySearchAPI");
        var result = await postRequestAsync(NEARBY_SEARCH_API, requestBody, fieldValues, cancellationToken);
        Debug.Log($"NearBySearchAPI 完了 : {result}");
        return JsonUtility.FromJson<ResponseBody>(result);
    }
    
    /// <summary>
    /// テキスト検索API(Google ※無料枠は$200/月 約6000回)
    /// ドキュメント:https://developers.google.com/maps/documentation/places/web-service/text-search?hl=ja
    /// </summary>
    /// <param name="placeName"></param>
    /// <param name="cancellationToken"></param>
    /// <returns></returns>
    public static async UniTask<ResponseBody> RequestTextSearchAsync(string placeName,int resultCount, LocationRestriction locationBias = null,CancellationToken cancellationToken = default)
    {
        var requestBody = new RequestBodyTextSearch
        {
            textQuery = placeName,
            maxResultCount = resultCount,
            locationBias = locationBias
        };

        //取得する情報の指定("*"で全指定できる)
        var fieldValues = new string[]
        {
            "places.displayName", 
            "places.shortFormattedAddress",
            "places.formattedAddress", 
            "places.primaryTypeDisplayName",
            "places.location",
            "places.photos"
        };
        
        Debug.Log("リクエスト:TextSearchAPI");
        var result = await postRequestAsync(TEXT_SEARCH_API,requestBody,fieldValues,cancellationToken);
        Debug.Log($"TextSearchAPI 完了 : {result}");
        return JsonTools.Deserialize<ResponseBody>(result);
    }
    
    /// <summary>
    /// Place Photo API
    /// ドキュメント:https://developers.google.com/maps/documentation/places/web-service/place-photos?hl=ja
    /// </summary>
    /// <param name="name">画像のリソース名(他APIのレスポンスのname)</param>
    /// <param name="maxHeightPx">最大の縦幅</param>
    /// <param name="maxWidthPx">最大の横幅</param>
    /// <param name="ct"></param>
    /// <returns></returns>
    public static async UniTask<ResponsePhoto> RequestPlacePhoto(string name, int maxHeightPx, int maxWidthPx, CancellationToken ct)
    {
        var args = new Dictionary<string, string>()
        {
            {"key",$"{API_KEY}"},
            {"maxHeightPx",$"{maxHeightPx}"},
            {"maxWidthPx",$"{maxWidthPx}"},
            {"skipHttpRedirect","true"}
        };
        var url = $"https://places.googleapis.com/v1/{name}/media";
        
        Debug.Log("リクエスト:PlacePhotoAPI");
        var result = await getRequestAsync(url,args,ct);
        Debug.Log($"PlacePhotoAPI:完了:{result}");
        return JsonTools.Deserialize<ResponsePhoto>(result);
    }
    #endregion
    
    #region PrivateMethod
    /// <summary>
    /// WebRequest(POST)
    /// </summary>
    /// <param name="url"></param>
    /// <param name="cancellationToken"></param>
    /// <returns></returns>
    private static async UniTask<string> postRequestAsync<TRequestBody>(string url,TRequestBody requestBody, string[] fieldValues ,CancellationToken cancellationToken)
    {
        using var request = new UnityWebRequest(url, UnityWebRequest.kHttpVerbPOST);
        byte[] bodyRaw = JsonTools.SerializeToUTF8(requestBody);
        request.uploadHandler = new UploadHandlerRaw(bodyRaw);
        request.downloadHandler = new DownloadHandlerBuffer();
        request.SetRequestHeader("Content-Type", "application/json");
        request.SetRequestHeader("X-Goog-Api-Key", API_KEY);
        var fieldMasksSb = new StringBuilder();
        fieldMasksSb.AppendJoin(",",fieldValues);
        request.SetRequestHeader("X-Goog-FieldMask", fieldMasksSb.ToString());
        
        await request.SendWebRequest().WithCancellation(cancellationToken);
        var result = request.downloadHandler.text;
        return result;
    }
    
    /// <summary>
    /// WebRequest(GET)
    /// </summary>
    /// <param name="url"></param>
    /// <param name="aug"></param>
    /// <param name="cancellationToken"></param>
    /// <returns></returns>
    private static async UniTask<string> getRequestAsync(string url, Dictionary<string,string> aug, CancellationToken cancellationToken)
    {
        bool isFirst = true;
        foreach (var keyAndValue in aug)
        {
            url += isFirst ? "?" : "&";
            if (isFirst) isFirst = false;
            url += $"{keyAndValue.Key}={keyAndValue.Value}";
        }
        
        using var request = new UnityWebRequest(url, UnityWebRequest.kHttpVerbGET)
        {
            downloadHandler = new DownloadHandlerBuffer()
        };
        await request.SendWebRequest().WithCancellation(cancellationToken);
        var result = request.downloadHandler.text;
        return result;
    }
    #endregion
    
}//GoogleAPI End

#region Models
/// <summary>
/// 付近の検索API(Google)のリクエストボディ
/// </summary>
[Serializable]
public class RequestBodyNearBySearch
{
    public string languageCode = "ja";
    public string rankPreference = "DISTANCE";
    public int maxResultCount = 10;
    public LocationRestriction locationRestriction;
}

/// <summary>
/// 文字列検索のリクエストボディ
/// </summary>
[Serializable]
public class RequestBodyTextSearch
{
    public string textQuery;
    public string languageCode = "ja";
    public int maxResultCount = 10;
    public LocationRestriction locationBias;
}

/// <summary>
/// API(Google)のレスポンス
/// </summary>
[Serializable]
public class ResponseBody
{
    public List<Place> places;
}

/// <summary>
/// 画像取得APIのレスポンス
/// </summary>
[Serializable]
public class ResponsePhoto
{
    public string name;
    public string photoUri;
}

/// <summary>
/// 施設情報
/// </summary>
[Serializable]
public class Place
{
    public string formattedAddress;
    public string shortFormattedAddress;
    public Location location;
    public DisplayText displayName;
    public DisplayText primaryTypeDisplayName;
    public List<Photo> photos;
}

/// <summary>
/// 表示テキスト
/// </summary>
[Serializable]
public class DisplayText
{
    public string text;
    public string languageCode;
}

/// <summary>
/// 画像情報
/// </summary>
[Serializable]
public class Photo
{
    /// <summary>
    /// PlacePhotoAPIで必要
    /// </summary>
    public string name;
    
    public int widthPx;
    public int heightPx;
    public List<AuthorAttribution> authorAttributions;
}

/// <summary>
/// 画像投稿者
/// </summary>
[Serializable]
public class AuthorAttribution
{
    public string displayName;
    public string uri;
    public string photoUri;
}

/// <summary>
/// 場所の指定
/// </summary>
[Serializable]
public class LocationRestriction
{
    public Circle circle;

    public LocationRestriction(double latitude, double longitude, float radius)
    {
        circle = new Circle()
        {
            center = new Location()
            {
                longitude = longitude,
                latitude = latitude,
            },
            radius = radius
        };
    }
}

/// <summary>
/// 円の範囲
/// </summary>
[Serializable]
public class Circle
{
    public Location center;
    public float radius;
}

/// <summary>
/// 緯度経度
/// </summary>
[Serializable]
public class Location
{
    public double latitude;
    public double longitude;
}

#endregion

WinVoiceRecognizer

WIndowsで音声認識を行うクラスの全容です。

/// <summary>
/// Windows用の音声認識
/// </summary>
public class WinVoiceRecognizer
{
    private Subject<string> _onResutSubject = new Subject<string>();
    private Subject<string> _onTimeoutSubject = new Subject<string>();
    private Subject<string> _onErrorSubject = new Subject<string>();
    
    private DictationRecognizer _dictationRecognizer;
    
    /// <summary>
    /// コンストラクタ
    /// </summary>
    /// <param name="timeout"></param>
    public WinVoiceRecognizer(float timeout = 15f)
    {
        _dictationRecognizer = new DictationRecognizer();
        _onResutSubject = new Subject<string>();
        _dictationRecognizer.AutoSilenceTimeoutSeconds = timeout;
        _dictationRecognizer.InitialSilenceTimeoutSeconds = timeout;
    }

    /// <summary>
    /// デストラクタ
    /// </summary>
    ~WinVoiceRecognizer()
    {
        StopReconize();
    }
    
    /// <summary>
    /// 音声認識を開始する
    /// </summary>
    /// <param name="ct">キャンセルトークン</param>
    /// <returns></returns>
    /// <exception cref="Exception">音声認識失敗</exception>
    public async UniTask<string> StartReconizeAsync(CancellationToken ct)
    {
        _dictationRecognizer.DictationResult += onDictationResult;
        _dictationRecognizer.DictationComplete += onDictationComplete;
        _dictationRecognizer.DictationError += onDictationError;
        _dictationRecognizer.Start();
        var resultTask = _onResutSubject.ToUniTask(true, cancellationToken:ct);
        var timeoutTask = _onTimeoutSubject.ToUniTask(true, cancellationToken: ct);
        var errorTask = _onErrorSubject.ToUniTask(true, cancellationToken:ct);
        var response= await UniTask.WhenAny(resultTask, timeoutTask, errorTask);
        if (timeoutTask.Status.IsCompleted())
        {
            throw new TimeoutException($"音声認識タイムアウト:{response.result2}");
        }
        if (errorTask.Status.IsCompleted())
        {
            throw new Exception($"音声認識エラー:{response.result3}");
        }
        return response.result1;
    }

    /// <summary>
    /// 音声認識を止める
    /// </summary>
    public void StopReconize()
    {
        _dictationRecognizer.DictationResult -= onDictationResult;
        _dictationRecognizer.DictationComplete -= onDictationComplete;
        _dictationRecognizer.DictationError  -= onDictationError;
        _dictationRecognizer.Stop();
    }
    
    /// <summary>
    /// フレーズ単位でテキストの取得に成功したとき
    /// </summary>
    /// <param name="text"></param>
    /// <param name="confidenceLevel"></param>
    private void onDictationResult(string text, ConfidenceLevel confidenceLevel)
    {
        _onResutSubject.OnNext(text);
    }
    
    /// <summary>
    /// 文章単位でテキストの取得に成功したとき
    /// </summary>
    /// <param name="completionCause"></param>
    private void onDictationComplete(DictationCompletionCause completionCause)
    {
        //タイムアウトは何故かここで返ってくる。
        _onTimeoutSubject.OnNext($"{completionCause}");
    }
    
    /// <summary>
    /// 何らかのエラーが発生したとき
    /// </summary>
    /// <param name="error"></param>
    /// <param name="hresult"></param>
    private void onDictationError(string error,int hresult)
    {
        _onErrorSubject.OnNext($"{hresult}:{error}");
    }
    
}//WinVoiceRecognizer End

OpenAiService

OepnAIのAPIを利用するクラスの全容です。

/// <summary>
/// OpenAIのAPIサービス
/// </summary>
public class OpenAiService
{
    private readonly string _apiKey;
    private ChatCompletionAPIConnection? _connection;
    private IChatMemory? _memory;

    /// <summary>
    /// コンストラクタ
    /// </summary>
    /// <param name="apiKey"></param>
    public OpenAiService(string apiKey)
    {
        _apiKey = apiKey;
    }

    /// <summary>
    /// デストラクタ
    /// </summary>
    ~OpenAiService()
    {
        ClearChatMemory();   
    }

    /// <summary>
    /// チャットの初期化
    /// </summary>
    /// <param name="systemMessage"></param>
    /// <param name="maxMemoryCount"></param>
    public void InitChat(string systemMessage = "", int maxMemoryCount = 20)
    {
        _memory = new FiniteQueueChatMemory(maxMemoryCount);
        _connection = new ChatCompletionAPIConnection(
            _apiKey,
            _memory,
            systemMessage);
    }

    /// <summary>
    /// チャットメモリーの削除
    /// </summary>
    public void ClearChatMemory()
    {
        _memory?.ClearAllMessages();
        _connection = null;
    }
    
    /// <summary>
    /// チャットでジオコーディングのリクエスト
    /// </summary>
    /// <param name="message"></param>
    /// <param name="model"></param>
    /// <param name="ct"></param>
    /// <returns></returns>
    public async UniTask<ResponseGeocoding> RequestGeocodingAsync(string message, Model model ,CancellationToken ct)
    {
    const string funcName = "geocoding";

    var function = new Function
    (
        name: funcName,
        description: "入力された場所そのまま(input)と指定された場所の名称(placeName)、緯度経度情報(latitude,longitude)を取得します。",
        parameters: new Dictionary<string, object>
        {
            { "type", "object" },
            {
                "properties", new Dictionary<string, object>
                {
                    {
                        "placeName", new Dictionary<string, object>
                        {
                            { "type", "string" },
                            {  "desciption" , "日本語表記で指定された場所 例:東京駅" }
                        }
                    },
                    {
                        "yomi", new Dictionary<string, object>
                        {
                            { "type", "string" },
                            {  "desciption" , "指定された場所の読み方 例:とうきょう えき" }
                        }
                    },
                    {
                        "input", new Dictionary<string, object>
                        {
                            { "type", "string" },
                            {  "desciption" , "入力文字列をそのまま記入 例:福井の県庁所在地" }
                        }
                    },
                    {
                        "address", new Dictionary<string, object>
                        {
                            { "type", "string" },
                            {  "desciption" , "指定された場所の住所 例:〒163-8001 東京都新宿区西新宿2丁目8-1" }
                        }
                    },
                    {
                        "latitude", new Dictionary<string, object>
                        {
                            { "type", "number" },
                            {  "desciption" , "Geographical WGS84 coordinate of the location" }
                        }
                    },
                    {
                        "longitude", new Dictionary<string, object>
                        {
                            { "type", "number" },
                            {  "desciption" , "Geographical WGS84 coordinate of the location" }
                        }
                    }
                }
            },
            { "required", new List<string> {"placeName","yomi","input","address","latitude","longitude"} },
        }
    );
    var responseStr = await completeChatFuncAsync(message, funcName, function, model, ct);
    return JsonConvert.DeserializeObject<ResponseGeocoding>(responseStr);
    }

    /// <summary>
    /// チャットから主語を抜き出す
    /// </summary>
    /// <param name="message"></param>
    /// <param name="model"></param>
    /// <param name="ct"></param>
    /// <returns></returns>
    public async UniTask<ResponseKeyword> RequestKeywordAsync(string message, Model model ,CancellationToken ct)
    {
        const string funcName = "getKeyword";
        var function = new Function
        (
            name: funcName,
            description: "主語の取得",
            parameters: new Dictionary<string, object>
            {
                { "type", "object" },
                {
                    "properties", new Dictionary<string, object>
                    {
                        {
                            "keyword", new Dictionary<string, object>
                            {
                                { "type", "string" },
                                {  "desciption" , "主語 例:東京の都庁、東京駅、レストラン、コンビニ、ラーメン、ホテル、病院" }
                            }
                        }
                    }
                },
                { "required", new List<string> {"keyword"} },
            }
        );
        
        var responseStr = await completeChatFuncAsync(message, funcName, function, model, ct);
        return JsonConvert.DeserializeObject<ResponseKeyword>(responseStr);
    }

    /// <summary>
    /// CompletionsAPI(Function Calling)を叩く
    /// </summary>
    /// <param name="message"></param>
    /// <param name="funcName"></param>
    /// <param name="function"></param>
    /// <param name="model"></param>
    /// <param name="ct"></param>
    /// <returns></returns>
    private async UniTask<string> completeChatFuncAsync(string message, string funcName , Function function, Model model , CancellationToken ct)
    {
        if (_connection == null)
        {
            Debug.LogError($"InitChat()を先に呼び出してください。");
            return null;
        }
        
        ChatCompletionResponseBody response;
        try
        {
            await UniTask.SwitchToThreadPool();
            
            response = await _connection
                .CompleteChatAsync(
                    message,
                    ct,
                    model: model,
                    functions: new List<Function> { function },
                    functionCallSpecifying: new FunctionCallSpecifying(name: funcName)
                );    
        }
        catch (Exception e)
        {
            Debug.LogError(e);
            if (e.InnerException.Message.Contains("Too Many Requests"))
            {
                throw new Exception("OpenAIの利用制限を超えています。");
            }
            return null;
        }
        finally
        {
            await UniTask.SwitchToMainThread(ct);   
        }

        return response.Choices[0].Message.FunctionCall.Arguments;
    }
    
}//OpenAiService End

/// <summary>
/// 地理座標のレスポンス
/// </summary>
[Serializable]
public class ResponseGeocoding
{
    public string placeName;
    public string yomi;
    public string input;
    public string address;
    public double latitude;
    public double longitude;

    public override string ToString()
    {
        var stb = new StringBuilder($"地名:{placeName},読み:{yomi}");
        stb.AppendLine("");
        stb.AppendLine($"入力文字列:{input}");
        stb.AppendLine($"住所:{address}");
        stb.AppendLine($"緯度:{latitude}");
        stb.AppendLine($"経度:{longitude}");
        return stb.ToString();
    }

    public bool IsEmpty()
    {
        return string.IsNullOrEmpty(placeName) || longitude == 0 || latitude == 0;
    }
}

/// <summary>
/// 主語のレスポンス
/// </summary>
[Serializable]
public class ResponseKeyword
{
    public string keyword;
}

まとめ

今回紹介したように緯度経度の情報とAPIを連携することで、アプリの可能性が大きく広がったと思います。外部のAPIと連携してオリジナリティあふれるアプリを作ってみてください。
最後の「カメラ編」では、等身大の歩く視点になってジオラマの中を歩いてみたいと思います。

この記事が気に入ったらサポートをしてみませんか?