Outlook
VSTO
FaceAPI
CognitiveServices

Cognitive ServicesのFace APIを使って、怒っている時にメールが送れないOutlookのアドインを作ってみる。

はじめに

「ついカッとなってやった、今は反省している。(´・ω・`)」
怒っている時にやったことって、往々にして後から後悔しますよね...(´・ω・`)

例えば、怒っている時にメールを送っても、ろくなことがないですよね。
ということで、
怒っている時にメールを送ることができないOutlookのアドインをざっくり作ってみました。

ざっくりした仕組み

Microsoftが提供している Cognitive ServicesFace API に、顔写真の感情認識ができる機能があります。

これを使って、Outookでメールを送信する前に感情を測定し、怒っていたらメールを送らせない。
という仕組みにしてみました。

できたもの

確認画面
メールを送信しようとすると、怒っているかの確認画面を表示するようにしました。
「怒っているか確認する!」 ボタンを押すとPCのフロントカメラで顔写真を撮影し、感情を測定します。

試してみる

※顔にはモザイク的なものをかけてあります。

1.png
無表情な感じだと「中立(Neutral)」のスコアがほぼ100%ですね。

2.png
ちょっと睨めつけてみたのですが、あんまりスコアが変わらず(´・ω・`)

3.png
全力の笑顔にしてみたら「幸福(Happiest)」が100%になった!(∩´∀`)∩

...

何回も試したのですが、怒りのスコアが上がらない(´;ω;`)ブワッ
もともとデフォルトで顔が笑っているとか、にやけているって言われるのですが、
MicrosoftのAIさん的にもそうらしいです(´・ω・`)
演技力の問題な気もしますが…(´・ω・`)

作ってみる

ということで、ざっくり作ってみます。
ソースは抜粋したものを載せているので、
実際に作ったものの一式は GitHub に置いておきました。

GitHub

GitHubはこちら

Microsoft Azure(Cognitive Services)への登録とFace APIの準備

割愛しますが「AI + Machine Learning」の「Face API」から新規作成できます。
「F0 Free」を選べば無料で使えます(∩´∀`)∩

VSTOアドインのプロジェクトを作成

VSTOアドインとして作成するので、
Visual Studioから「Outlook 2013 and 2016 VSTO Add-in」の新規プロジェクトを作成します。

表示するウインドウのプロジェクトを作成

VSTO アドインは、デフォルトではWindows Formsですが、
表示するウインドウには、WPFを使いたかったので「WPF User Control Library」のプロジェクトを追加しました。
以降、NamespaceがCameraPreviewになっているものは、こちらのプロジェクトです。

Face APIを使って顔写真の感情を分析できるようにする

Nuget Packageの追加

Microsoft.Azure.CognitiveServices.Vision.Face というNuGetパッケージを使用すると、
簡単にFace APIを使うことができるので、追加します。
※プレリリースを含める にチェックを入れないと一覧に表示されません。

GetEmotion.cs

以下を渡すと、年齢と性別と感情を返してくれるクラスを作ります。
これ単体で使いまわせるので、他に何か作る時も楽な気がします。

  • 顔写真ファイルのパス
  • Face APIのキー
  • エンドポイントのURL
using Microsoft.Azure.CognitiveServices.Vision.Face;
using Microsoft.Azure.CognitiveServices.Vision.Face.Models;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;

namespace CameraPreview
{
    public class GetEmotion
    {
        private readonly FaceAttributeType[] _faceAttributes = { FaceAttributeType.Age, FaceAttributeType.Gender, FaceAttributeType.Emotion };

        public async Task<List<Attributes>> FromFilePathAsync(string filePath, string subscriptionKey, string endpoint)
        {
            var faceClient = new FaceClient(new ApiKeyServiceClientCredentials(subscriptionKey)) { Endpoint = endpoint };
            try
            {
                return  await DetectLocalAsync(faceClient, filePath);
            }
            catch (Exception e)
            {
                Console.WriteLine(e.Message);
                return null;
            }
        }

        private async Task<List<Attributes>> DetectLocalAsync(IFaceClient faceClient, string imagePath)
        {
            if (!File.Exists(imagePath))
            {
                return null;
            }

            try
            {
                using (Stream imageStream = File.OpenRead(imagePath))
                {
                    var faceList = await faceClient.Face.DetectWithStreamAsync(imageStream, true, false, _faceAttributes);
                    return GetFaceAttributes(faceList);
                }
            }
            catch (Exception e)
            {
                Console.WriteLine(e.Message);
                return null;
            }
        }

        private List<Attributes> GetFaceAttributes(IEnumerable<DetectedFace> faceList)
        {
            var detectedFaces = faceList.ToList();

            if (!detectedFaces.Any())
            {
                return null;
            }

            return detectedFaces.Select(face => new Attributes
            {
                Age = face.FaceAttributes.Age,
                Gender = face.FaceAttributes.Gender.ToString(),
                Anger = face.FaceAttributes.Emotion.Anger,
                Contempt = face.FaceAttributes.Emotion.Contempt,
                Disgust = face.FaceAttributes.Emotion.Disgust,
                Fear = face.FaceAttributes.Emotion.Fear,
                Happiness = face.FaceAttributes.Emotion.Happiness,
                Neutral = face.FaceAttributes.Emotion.Neutral,
                Sadness = face.FaceAttributes.Emotion.Sadness,
                Surprise = face.FaceAttributes.Emotion.Surprise
            }
            ).ToList();
        }
    }
}

カメラで顔写真を撮影して、感情を測定できるようにする

PreviewWindow.xaml

表示する画面です。

<Window x:Class="CameraPreview.PreviewWindow"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008" 
             xmlns:local="clr-namespace:CameraPreview"
             mc:Ignorable="d" 
             Title="怒っているか確認してからメールしよう!" Height="700" Width="550" ResizeMode="NoResize" ShowInTaskbar="False" WindowStartupLocation="CenterOwner" >

    <StackPanel Background="Black">
        <Image x:Name="PreviewImage" Height="370" Width="530" Margin="2,2,2,6"/>
        <Button Content="怒っているか確認する!" Margin="5" Click="CheckButton_OnClick" Height="30" HorizontalAlignment="Right" Width="310" IsDefault="true"/>
        <StackPanel Orientation="Horizontal" HorizontalAlignment="Right" >
            <Button x:Name="SendButton" Content="メール送信" Margin="5" Click="SendButton_OnClick" Height="30" Width="100" IsEnabled="false"/>
            <Button Content="キャンセル" Margin="5" Click="CancelButton_OnClick" Height="30" Width="200" IsCancel="true"/>
        </StackPanel>
        <TextBox x:Name="TextBox" Height="200" IsReadOnly="True"/>
    </StackPanel>
</Window>

PreviewWindow.xaml.cs

べたっとコードビハインドにまとめて書いちゃいました。
Windows.Media.Captureなど、UWPのAPIを使用すると楽なので、それを使用しています。

フロントカメラで写真を撮影し、テンポラリフォルダに保存して、
その写真から感情を測定して、画面に表示しています。
また、怒り(Anger)のスコアが0.6以下の場合に、送信ボタンを押せるようにしています。

using System;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Windows;
using System.Windows.Media.Imaging;
using Windows.Devices.Enumeration;
using Windows.Media.Capture;
using Windows.Media.MediaProperties;
using Windows.Storage.Streams;

namespace CameraPreview
{
    public partial class PreviewWindow : Window
    {
        public PreviewWindow()
        {
            InitializeComponent();
        }

        public async void FaceCapture()
        {
            var setting = new MediaCaptureInitializationSettings
            {
                StreamingCaptureMode = StreamingCaptureMode.Video,
                PhotoCaptureSource = PhotoCaptureSource.Auto
            };

            var devices = await DeviceInformation.FindAllAsync(DeviceClass.VideoCapture);
            setting.VideoDeviceId = devices.FirstOrDefault(item => item.EnclosureLocation != null && item.EnclosureLocation.Panel == Windows.Devices.Enumeration.Panel.Front)?.Id ?? throw new InvalidOperationException();

            using (var mediaCapture = new MediaCapture())
            {
                await mediaCapture.InitializeAsync(setting);
                mediaCapture.VideoDeviceController.Brightness.TrySetAuto(true);
                mediaCapture.VideoDeviceController.Contrast.TrySetAuto(true);

                var pngProperties = ImageEncodingProperties.CreatePng();
                pngProperties.Width = (uint)PreviewImage.ActualWidth;
                pngProperties.Height = (uint)PreviewImage.ActualHeight;

                using (var randomAccessStream = new InMemoryRandomAccessStream())
                {
                    await mediaCapture.CapturePhotoToStreamAsync(pngProperties, randomAccessStream);
                    randomAccessStream.Seek(0);

                    var bitmapImage = new BitmapImage();
                    using (var stream = randomAccessStream.AsStream())
                    {
                        bitmapImage.BeginInit();
                        bitmapImage.CacheOption = BitmapCacheOption.OnLoad;
                        bitmapImage.StreamSource = stream;
                        bitmapImage.EndInit();
                    }

                    PreviewImage.Source = bitmapImage;

                    var filePath = Path.GetTempPath() + "AngryMailTemp.png";
                    SaveBitmapSourceToFile(bitmapImage, filePath);

                    GEmotionAsync();
                }
            }
        }

        private async void GEmotionAsync()
        {
            var filePath = Path.GetTempPath() + "AngryMailTemp.png";
            var getEmotion = new GetEmotion();
            var attributes = await getEmotion.FromFilePathAsync(filePath, "Your Subscription Key", "https://japaneast.api.cognitive.microsoft.com/");

            if (attributes == null)
            {
                TextBox.Text = @"顔写真がイマイチです(´・ω・`)";
                File.Delete(filePath);
                return;
            }

            foreach (var attribute in attributes)
            {
                TextBox.Text = "年齢: " + attribute.Age?.ToString(CultureInfo.InvariantCulture) + Environment.NewLine +
                               "性別: " + attribute.Gender + Environment.NewLine +
                               Environment.NewLine +
                               "怒り: " + attribute.Anger.ToString(CultureInfo.InvariantCulture) + Environment.NewLine +
                               "軽蔑: " + attribute.Contempt.ToString(CultureInfo.InvariantCulture) + Environment.NewLine +
                               "嫌悪: " + attribute.Disgust.ToString(CultureInfo.InvariantCulture) + Environment.NewLine +
                               "恐怖: " + attribute.Fear.ToString(CultureInfo.InvariantCulture) + Environment.NewLine +
                               "幸福: " + attribute.Happiness.ToString(CultureInfo.InvariantCulture) + Environment.NewLine +
                               "中立: " + attribute.Neutral.ToString(CultureInfo.InvariantCulture) + Environment.NewLine +
                               "悲哀: " + attribute.Sadness.ToString(CultureInfo.InvariantCulture) + Environment.NewLine +
                               "驚き: " + attribute.Surprise.ToString(CultureInfo.InvariantCulture);

                if (attribute.Anger <= 0.6)
                {
                    SendButton.IsEnabled = true;
                }
            }

            File.Delete(filePath);
        }

        private static void SaveBitmapSourceToFile(BitmapSource bitmapSource, string filePath)
        {
            using (var fileStream = new FileStream(filePath, FileMode.Create))
            {
                BitmapEncoder encoder = new PngBitmapEncoder();
                encoder.Frames.Add(BitmapFrame.Create(bitmapSource));
                encoder.Save(fileStream);
            }
        }

        private void CheckButton_OnClick(object sender, RoutedEventArgs e)
        {
            FaceCapture();
        }

        private void CancelButton_OnClick(object sender, RoutedEventArgs e)
        {
            DialogResult = false;
        }

        private void SendButton_OnClick(object sender, RoutedEventArgs e)
        {
            DialogResult = true;
        }
    }
}

VSTOアドインを作る

アドイン側はシンプルです。メールの送信時に↑で作った画面を表示するようにしています。

ThisAddIn.cs

using CameraPreview;
using System.Windows.Interop;
using Outlook = Microsoft.Office.Interop.Outlook;

namespace AngryMail
{
    public partial class ThisAddIn
    {
        private void ThisAddIn_Startup(object sender, System.EventArgs e)
        {
            Application.ItemSend += Application_ItemSend;
        }

        private void Application_ItemSend(object item, ref bool cancel)
        {
            //MailItemにキャストできないものは会議招待などメールではないものなので、何もしない。
            if (!(item is Outlook._MailItem)) return;

            var previewWindow = new PreviewWindow();
            var activeWindow = Globals.ThisAddIn.Application.ActiveWindow();
            var outlookHandle = new NativeMethods(activeWindow).Handle;
            var windowInteropHelper = new WindowInteropHelper(previewWindow) { Owner = outlookHandle };
            var dialogResult = previewWindow.ShowDialog();

            if (dialogResult == true)
            {
                //Send Mail.
            }
            else
            {
                cancel = true;
            }
        }

        private void InternalStartup() => Startup += ThisAddIn_Startup;
    }
}

おわりに

怒った顔の演技は難しいですね...(´・ω・`)

Cognitive Servicesならお手軽にAIを使えるので、
ちょっとした思い付きでも、楽しいものや便利なものが作れそうですね。