Microsoft Cognitive Service を使って Meetup イベントを最適化してみた

昨年 12 月に Azure Meetup で Microsoft Cognitive Service のセッションをやりましたが、その時に紹介したアプリケーションを公開したのでブログでもお知らせを。

Tokyo Azure Meetup #11 - Building Intelligent Applications (46:30 あたりからです。)
https://www.youtube.com/watch?v=UH3oJ-gfze8

ソースコード
https://github.com/kenakamu/AttendeeAnalyzer

なにするアプリケーション?

私も Meetup のイベントを主催していますが、いくつか面倒なことがあり、今回のアプリケーションはそれらを改善するために作りました。

参加者の管理: イベント参加者は RSVP してくれますが、実際に来るかというと話は別。ということで実際に来た人のトラック
QA の管理: イベント中に質問が出た場合、回答した/しなかったに関わらず情報は共有したい。よってそれらの管理。
英語: 質問してくれるのはありがたいんですが、たまに英語が聞き取れない。よってその対策。

使っている API や主要テクノロジー

Microsoft Cognitive Face API: https://www.microsoft.com/cognitive-services/en-us/face-api
Microsoft Cognitive Emotion API: https://www.microsoft.com/cognitive-services/en-us/emotion-api
SignalR: https://www.asp.net/signalr
Meetup.com API: https://www.meetup.com/meetup_api
UWP Speech Recognizer: https://docs.microsoft.com/en-us/windows/uwp/input-and-devices/speech-recognition
UWP Face Detector: https://docs.microsoft.com/en-us/windows/uwp/audio-video-camera/detect-and-track-faces-in-an-image

試し方

GitHub からソリューションを取得していただき、AttendeeAnalyzerPCL\Settings.cs ファイルの各種キーを指定してください。
キーは以下の方法で取得できます。

Microsoft Cognitive Service: Azure ポータルか、https://www.microsoft.com/cognitive-services/en-us/subscriptions より体験版を取得可能。
Meetup.com: https://secure.meetup.com/meetup_api/key/ より取得可能。自分のグループない場合は適当に人のを使うのもあり?

では実際のアプリケーションを紹介します。

Attendee Register

参加者の管理用アプリケーション。以下のようなフローとコードで動作します。

直近の Meetup イベントより RSVP 情報を取得

Meetup.com は REST で API を公開しているため、HttpClient と JSON.NET で簡単に情報を取得できます。また同時に参加者が来場した際にイベントページに書き込む Welcome コメントも取得して、だれが既に来場済かを取得しておきます。

 public async Task<List<RSVP>> GetRSVPsAsync(string eventId)
{
    using (HttpClient client = GetClient())
    {
        var result = await client.GetAsync(string.Format(rsvpEndpoint, Settings.GroupName, eventId, Settings.MeetupAPIKey));
        if (result.IsSuccessStatusCode)
            return JsonConvert.DeserializeObject<List<RSVP>>(await result.Content.ReadAsStringAsync());
        else
            return null;
    }
}

RSVP 済のメンバーのうち Face API 未登録者を登録

まだイベントに来たことがないメンバーは Cognitive Face API 上に存在しないため、Person というレコードを追加します。顔写真情報は後のフローで登録しますが、メンバー名など付加情報は Person に UserData という形で登録します。

 registeredPersons = (await faceClient.GetPersonsAsync(Settings.PersonGroupId)).ToList();
RSVPs = await meetupService.GetRSVPsAsync(currentEvent.Id);


// Check if RSVPed meetup member is registered to Face API.
foreach (RSVP rsvp in RSVPs)
{
    var registeredPerson = registeredPersons.FirstOrDefault(x => x.Name == rsvp.Member.Name);
    if (registeredPerson == null)
    {
        var userData = new JObject();
        userData["memberId"] = rsvp.Member.Id;
        var createdPersonResult = await faceClient.CreatePersonAsync(Settings.PersonGroupId, rsvp.Member.Name, userData.ToString());
        registeredPersons.Add(await faceClient.GetPersonAsync(Settings.PersonGroupId, createdPersonResult.PersonId));
    }
}

カメラを起動し、人の顔が映るまでひたすら写真を撮っては捨てる

画像に人の顔が映っているかは Cognitive Service でも確認できますが、通信と処理の無駄となるため、UWP ローカルの機能でまず顔が映っているか確認します。

 private async Task<InMemoryRandomAccessStream> DetectFaceAsync()
{
    var imgFormat = ImageEncodingProperties.CreateJpeg();

    while (true)
    {
        var stream = new InMemoryRandomAccessStream();
        await mediaCapture.CapturePhotoToStreamAsync(imgFormat, stream);

        var image = await ImageConverter.ConvertToSoftwareBitmapAsync(stream);
        detectedFaces = await faceDetector.DetectFacesAsync(image);

        if (detectedFaces.Count == 0)
            continue;
        else if (detectedFaces.Count != 1)
            Message = "too many faces!";
        else
            return stream;
    }
}

Face API で参加者を識別

写真に顔が映っている場合は、Cognitive Face API で登録済の顔と比較し参加者を識別します。ローカルで顔が認識されても Face API で認識されない場合もあるので、一旦 DetectAsync メソッドを実行しています。認識ができたら、Meetup API でイベントのページに Welcome コメントを書き込み来た人を管理します。まだ Face API に登録されていない場合は登録フローに行きます。

 var faces = await faceClient.DetectAsync(ImageConverter.ConvertImage(stream));

if (!faces.Any())
    continue;
try
{
    Person person;

    var identifyResults = await faceClient.IdentifyAsync(Settings.PersonGroupId, faces.Select(x => x.FaceId).ToArray());
    if (identifyResults.FirstOrDefault()?.Candidates?.Count() > 0)
        person = await faceClient.GetPersonAsync(Settings.PersonGroupId, identifyResults.First().Candidates.First().PersonId);
    else
        person = await RegisterAsync(stream);

    // If welcome comment not posted yet, then post it.
    if (RSVPComments.FirstOrDefault(x => x.CommentDetail.Contains(person.Name)) == null)
        RSVPComments.Add(await meetupService.PostCommentAsync("Welcome " + person.Name, currentEvent.Id));

    Message = $"Hi {person.Name}!";
    SelectedRSVP = null;
    await Task.Delay(2000);
}

Face API へ登録

はじめてイベントに参加する場合はまだ顔情報が無いため登録します。この際 RSVP 情報を選択してもらうことで名前を確認します。重要なポイントは顔登録後、TrainPersonGroup メソッドを実行することです。これをしないと学習しないため顔が認識されません。

 private async Task<Person> RegisterAsync(InMemoryRandomAccessStream stream)
{
    if (SelectedRSVP == null)
        Message = "Select your RSVP.";

    while (SelectedRSVP == null)
    {
        await Task.Delay(1000);
    }            

    // All the members should be registered when initialized.
    var registeredPerson = registeredPersons.First(x => x.Name == SelectedRSVP.Member.Name);

    // Register face information and discard image.
    var addPersistedFaceResult = await faceClient.AddPersonFaceAsync(
        Settings.PersonGroupId, registeredPerson.PersonId, ImageConverter.ConvertImage(stream));
    stream.Dispose();

    await faceClient.TrainPersonGroupAsync(Settings.PersonGroupId);
    return await faceClient.GetPersonAsync(Settings.PersonGroupId, registeredPerson.PersonId);
}

上記のような仕組みで来場者管理を簡単にするだけでなく、次回以降のイベントでは参加者が RSVP を指定することなく認識可能となります。唯一の課題はこのアプリケーションのカメラの設置場所を工夫して、全参加者がちゃんと認識されるようにすることですが会場によっては。。。

Attendee Listener

参加者の質問を拾うためのアプリケーション。以下のようなフローとコードで動作します。

Speech Recgonizer の初期化

Speech-To-Text を利用するための初期化を行います。音声は話している途中のものと、話し終わったもの両方が取得可能です。 話し終わったかどうかなどは各種タイムアウト値を調整することで設定が可能です。HypothesisGenerated が途中の結果、Completed が最終結果を返すイベントです。

 // Initialize Speech recognizer and subscribe event.
speechRecognizer = new SpeechRecognizer(new Windows.Globalization.Language(Settings.SpeechLanguage));
speechRecognizer.Timeouts.BabbleTimeout = TimeSpan.FromSeconds(25);
speechRecognizer.Timeouts.InitialSilenceTimeout = TimeSpan.FromSeconds(50);
speechRecognizer.Timeouts.EndSilenceTimeout = TimeSpan.FromMilliseconds(50);

speechRecognizer.ContinuousRecognitionSession.ResultGenerated += ContinuousRecognitionSession_ResultGenerated;
speechRecognizer.HypothesisGenerated += SpeechRecognizer_HypothesisGenerated;
speechRecognizer.ContinuousRecognitionSession.Completed += ContinuousRecognitionSession_Completed;
await speechRecognizer.CompileConstraintsAsync();

SignalR クライアントの初期化

SignalR サーバーから情報を取得した際の処理を指定するなど初期化します。このアプリケーションでは Speaker Portal からのコマンドを取得した際の処理を指定します。送られてくるコマンドはマイクの有効化や無効化、データの初期化になります。

 // Setup SignalR client and subscribe events.
hubConnection = new HubConnection(Settings.HubUrl);
hubProxy = hubConnection.CreateHubProxy(Settings.HubName);
hubProxy.On("BroadcastStartQuestion", async () =>
{
    await CoreApplication.MainView.CoreWindow.Dispatcher.RunAsync(
            CoreDispatcherPriority.Normal, async () => { await StartQuestionAsync(); });
});
hubProxy.On("BroadcastStopQuestion", async () =>
{
    await CoreApplication.MainView.CoreWindow.Dispatcher.RunAsync(
        CoreDispatcherPriority.Normal, async () => { await StopQuestionAsync(); });
});
hubProxy.On("BroadcastClear", async () =>
{
    await CoreApplication.MainView.CoreWindow.Dispatcher.RunAsync(
        CoreDispatcherPriority.Normal, async () => { await ClearInputAsync(); });
});
await hubConnection.Start();

カメラを起動し、人の顔が映るまでひたすら写真を撮っては捨てる

これは上記の Attendee Register と一緒です。質問者がマイクの前に立つのをひたすら待ちます。

Face API で参加者を識別

これも基本動作は同じですが、参加者を認識するとその情報を SignalR サーバーへ送信します。この情報は Speaker Portal アプリケーションで利用します。

 var faces = await faceClient.DetectAsync(ImageConverter.ConvertImage(stream));
if (!faces.Any())
{
    continue;
}
else
{
    try
    {
        var identifyResults = await faceClient.IdentifyAsync(Settings.PersonGroupId, faces.Select(x => x.FaceId).ToArray());
        if (identifyResults.FirstOrDefault()?.Candidates?.Any() ?? false)
        {
            var personId = identifyResults.First().Candidates.First().PersonId;
            var person = await faceClient.GetPersonAsync(Settings.PersonGroupId, personId);
            Message = $"Hi {person.Name}";
            var userData = JToken.Parse(person.UserData);
            var id = userData["memberId"].ToString();
            await hubProxy.Invoke("SendId", JToken.Parse(person.UserData)["memberId"].ToString());
            getEmotion = true;
            break;
        }
    }
    catch
    {
        Message = "Please register yourself first.";
        await Task.Delay(2000);
    }
}

Emotion API で質問者の表情を取得

質問している間や、スピーカーの回答を聞いている間の表情を取得して、SignalR サーバーに送信します。尚、GetHighestEmotion メソッドはローカル関数で一番大きな感情を取得する単純なものです。

 private async void GetEmotion()
{
    while (true)
    {
        if (getEmotion)
        {
            try
            {
                var stream = await DetectFaceAsync();
                var emotions = await emotionClient.RecognizeAsync(ImageConverter.ConvertImage(stream));
                var result = GetHighestEmotion(emotions.First().Scores);
                await hubProxy.Invoke("SendEmotionScoreResult", result);
            }
            catch { /* hide error */ }
        }
        await Task.Delay(2000);
    }
}

音声認識の開始と停止

Speaker Portal からコマンドが来たら、音声認識を開始/停止します。

 Message = "Starting Microphone...";
keepListen = true;
while (speechRecognizer.State == SpeechRecognizerState.Idle)
{
    try { await speechRecognizer.ContinuousRecognitionSession.StartAsync(SpeechContinuousRecognitionMode.Default); await Task.Delay(100); }
    catch { /* hide error */ }
}
Message = "Microphone started...";
 Message = "Stopping Microphone...";
keepListen = false;
while (speechRecognizer.State != SpeechRecognizerState.Idle)
{
    try { await speechRecognizer.ContinuousRecognitionSession.StopAsync(); await Task.Delay(100); }
    catch { /* hide error */ }
}
Message = "Microphone stopped...";

質問の音声をテキスト化

UWP の Speech Recognition 機能を利用して、質問の内容を音声からテキストにします。途中結果も最終結果もそれぞれ SignalR サーバーへ送信します。

 private async void ContinuousRecognitionSession_ResultGenerated(SpeechContinuousRecognitionSession sender, SpeechContinuousRecognitionResultGeneratedEventArgs args)
{
    await CoreApplication.MainView.CoreWindow.Dispatcher.RunAsync(CoreDispatcherPriority.Normal, () =>
    {
        dictatedTextBuilder.Append(args.Result.Text + ". ");
        QuestionText = dictatedTextBuilder.ToString();
        hubProxy.Invoke("SendQuestionText", dictatedTextBuilder.ToString());
    });
}

/// <summary>
/// Called while speech is partically recognized
/// </summary>
private async void SpeechRecognizer_HypothesisGenerated(SpeechRecognizer sender, SpeechRecognitionHypothesisGeneratedEventArgs args)
{
    await CoreApplication.MainView.CoreWindow.Dispatcher.RunAsync(CoreDispatcherPriority.Normal, () =>
    {
        QuestionHypo = args.Hypothesis.Text;
        hubProxy.Invoke("SendQuestionHypo", args.Hypothesis.Text);
    });
}

Speaker Portal

スピーカーを助けるアプリケーション。以下のようなフローとコードで動作します。

Speech Recgonizer の初期化

Speech-To-Text を利用するための初期化を行います。上記と同じコードです。

SignalR クライアントの初期化

SignalR サーバーから情報を取得した際の処理を指定するなど初期化します。このアプリケーションでは質問者の情報を取得した場合や質問のテキストを取得した場合の処理を指定します。

 // Setup SignalR client and subscribe events.
hubConnection = new HubConnection(Settings.HubUrl);
hubProxy = hubConnection.CreateHubProxy(Settings.HubName);
hubProxy.On<string>("BroadcastId", async (id) =>
{
    await CoreApplication.MainView.CoreWindow.Dispatcher.RunAsync(
        CoreDispatcherPriority.Normal, async () => { await SetMemberAsync(id); });                
});
hubProxy.On<string>("BroadcastQuestionText", async text =>
{
    await CoreApplication.MainView.CoreWindow.Dispatcher.RunAsync(
        CoreDispatcherPriority.Normal, () => { QuestionText = text; });
});
hubProxy.On<string>("BroadcastQuestionHypo", async hypo =>
{
    await CoreApplication.MainView.CoreWindow.Dispatcher.RunAsync(
        CoreDispatcherPriority.Normal, () => { QuestionHypo = hypo; });
});
hubProxy.On<EmotionScoreResult>("BroadcastEmotionScoreResult", async result =>
{
    await CoreApplication.MainView.CoreWindow.Dispatcher.RunAsync(
        CoreDispatcherPriority.Normal, () => { EmotionScoreResult = result; });
});
await hubConnection.Start();

質問者の情報取得

質問者がマイクの前に立つと、上記コードで識別された結果が SignalR サーバーに送られます。この情報を取得したらすぐに質問者の詳細情報を取得して画面に表示します。

 private async Task SetMemberAsync(string id)
{
    Member = await meetupService.GetMemberAsync(id);
    Topics = string.Join(",", Member.Topics.ToList());

    var groups = await meetupService.GetGroupsOfMemberAsync(id);
    Groups = $@"Tech: {string.Join(",", groups.Where(x => x.Category.Name == "tech"))}
Other: {string.Join(",", groups.Where(x => x.Category.Name != "tech"))}
";           
}

音声認識の開始と停止

アプリケーション上のボタンをクリックして、音声認識の開始と停止を行います。Attendee Listner の音声認識を開始/停止する場合は SignalR サーバーにコマンドを送ります。

  await hubProxy.Invoke("StartQuestion");

QA の保存

質問と回答をテキスト化したら、その内容をイベントのコメントとして保存します。

 public async void SaveQA()
{
    await meetupService.PostCommentAsync($"{Member.Name} asked. Question: {QuestionText} Answer:{AnswerText}", currentEvent.Id);
    Message = "QA Saved!";
}

Attendee Connector

Attendee Listener と Speaker Portal をリアルタイムにつなぐための SignalR サービスです。コードは割愛。

各種共通コード

今回プロジェクトが複数あるため、モデルやサービスなど共通化できるところは PCL にしています。

まとめ

今回のアプリケーションと作ってみて学んだことをまとめておきます。

- ローカルで処理できることは通信やお金の節約のためにもローカルで処理する。(音声や顔認識など)
- 顔検知はローカルと Cognitive Service で若干違うときがある。
- 複数の顔が映る場合などの処理をどうするか、慎重に検討する余地がある。今回は無視しましたが。。。
- データソースが必要な API を提供していない場合には工夫が必要。Connpass などは全然 API が。。。
- 極力有線でつないでおく。またインターネット接続に問題がある場合の処理も検討しておく。
- 課金状況は常に見ておく。お金大事です。。。

最後に、Special Thanks to かずき!! (https://blog.okazuki.jp)
コードレビューありがとうー

中村 憲一郎