砂漠の旅人(たびと)

UNIX / MS-DOS 時代から電脳砂漠を旅しています

【Video Analyzer】C# アプリから Microsoft Azure AI サービスを利用するには

こんにちは、たびとです。

前回、Microsoft Azure AI サービスの Video Analyzer (旧 : Video Indexer) の概要を説明しました。 Web 画面から簡単に学習済みの AI サービスを利用できるため、映像・音声の AI 解析としては敷居が低いと思います。 今回は、Video Analyzer を C# を使ってアプリ化する 方法を紹介します。

今回の内容は、Video Analyzer の基礎知識が前提となります。 初めての方は、前回の記事を参考にしてください。

sabakunotabito.hatenablog.com

この記事の対象者

  • Microsoft Azure AI サービス Video Analyzer に興味のある方
  • AI アプリを C# で作る方法を知りたい方

事前準備

今回のサンプルは、Visual Studio 2022 のコンソールアプリ (.NET 6) で作成しています。 また、 Newtonsoft.Json RestSharp を使うため、 NuGet パッケージマネージャーからインストールしてください。

Newtonsoft.Json (Json.Net)

AI サービスの結果は、JSON データになるため、JSON ツールは必須となります。 MicrosoftJSON も使えるようになってきましたが、 まだ性能的にはこちらの JSON の方がよさそうです。

www.newtonsoft.com

RestSharp

Microsoft AI サービスを REST API から利用するための便利ツールです。 そのまんまの名称ですね。

restsharp.dev

昔、C# ライブラリも試してみましたが、 当時は REST API の方が新しかったという過去があります。

今回実装する API

今回のアプリに実装したのは、5 つの基本的な API です。 ここでは「ビデオ=動画ファイル」とします。

API 説明
Get Access Token Video Analyzer を利用するためのトークンを取得します。
Upload Video ビデオをアップロードし、学習済みAIで解析します。
List Video アップロードしたビデオの一覧をJSON形式で取得します。
Get Video Index 学習済みAIで解析した情報をJSON形式で取得します。
Get Video Captions 音声認識により音声をテキストに変換した文字列を取得します。

アップロード

最初は、ビデオ(動画ファイル)をアップロードする API からです。 この API を使うと Video Analyzer にビデオをアップロードした後、 学習済み AI による解析が始まります。

動作済みのソースコードを掲載します。 accountId と apiKey の値は、前回の記事を参考にしてください。

このアップロードは同じファイルを何回もアップロードしても、 別のビデオ ID が割り振られる ことに注意してください。

using Newtonsoft.Json;
using RestSharp;
using System.Net;

//Video Analyzer定義
const string VideoIndexerUri = @"https://api.videoindexer.ai";
var location = "trial";
var accountId = "<アカウントID>";
var apiKey = "<Video Analyzer Subscriptions の Primary Key または Secondary Key>";

//アクセス準備
var client = new RestClient(baseUrl: VideoIndexerUri);                  // Video Indexer URL
client.AddDefaultHeader("x-ms-client-request-id", string.Empty);
client.AddDefaultHeader("Ocp-Apim-Subscription-Key", apiKey);

var accessToken = GetToken();
if (accessToken == null)
{
    Console.Error.WriteLine("ERROR:アクセストークンの取得に失敗しました。");
    return;
}
Console.WriteLine(accessToken);

var path = @"<適当なビデオファイル名>.mp4";
if (!Upload(path))
{
    Console.Error.WriteLine("ERROR:ビデオファイルのアップロードに失敗しました。");
    return;
}

//VideoAnalyzerトークン取得
string? GetToken()
{
    Console.WriteLine("Access-Token.");
    var request = new RestRequest($"Auth/{location}/Accounts/{accountId}/AccessToken", Method.Get);
    request.AddParameter("allowEdit", "true", ParameterType.GetOrPost);
    var response = client.ExecuteAsync(request).Result;
    if (HttpStatusCode.OK != response.StatusCode)
    {
        Console.Error.WriteLine($"ERROR: {response.StatusCode}={response.Content}");
        return null;
    }
    var token = JsonConvert.DeserializeObject<string>(response.Content);
    return token;
}

//アップロード(AIに解析を依頼する)
bool Upload(string path)
{
    Console.WriteLine("Upload.");
    var name = "サンプルビデオ";
    var privacy = "Private";            // Private|Public
    var language = "auto";              // auto|en-GB|en-US|ja-JP
    var indexingPreset = "Default";     // Default|AudioOnly|VideoOnly|DefaultWithNoiseReduction
    var streamingPreset = "Default";    // NoStreaming|Default|SingleBitrate|AdaptiveBitrate
    var request = new RestRequest($"{location}/Accounts/{accountId}/Videos", Method.Post);
    request.AlwaysMultipartFormData = true;
    request.AddHeader("Content-Type", "multipart/form-data");
    request.AddParameter("name", name, ParameterType.QueryString);
    request.AddParameter("privacy", privacy, ParameterType.QueryString);
    request.AddParameter("language", language, ParameterType.QueryString);
    request.AddParameter("indexingPreset", indexingPreset, ParameterType.QueryString);
    request.AddParameter("streamingPreset", streamingPreset, ParameterType.QueryString);
    request.AddParameter("accessToken", accessToken, ParameterType.QueryString);
    request.AddFile("video_file", path, "application/octet-stream");
    var response = client.ExecuteAsync(request).Result;
    if (HttpStatusCode.OK != response.StatusCode)
    {
        Console.Error.WriteLine($"ERROR: {response.StatusCode}={response.Content}");
        return false;
    }
    Console.WriteLine("Uploaded.");
    return true;
}

リクエストのパラメータは、標準のままでも特に問題がないと思いますが、 細かく指定したい方はマニュアルを参照してください。

ビデオをアップロードすると、Video Analyzer の Web 画面からも解析状態を確認することができます。

www.videoindexer.ai

例えば、昔 Channel 9 (現在 Microsoft Learn の一部) からダウンロードしたクラウディアの ビデオ(動画ファイル)が手元にあったのでアップロードしてみました。 解析中の背景にクラウディアが薄っすらと表示されて、解析中のビデオを確認することができます。

アップロード後に解析の進捗が表示されます

解析データの取得

Video Analyzer の解析結果を取得するソースコードの全文です。

using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using RestSharp;
using System.Net;

//Video Analyzer定義
const string VideoIndexerUri = @"https://api.videoindexer.ai";
var location = "trial";
var accountId = "<アカウントID>";
var apiKey = "<Video Analyzer Subscriptions の Primary Key または Secondary Key>";

//アクセス準備
var client = new RestClient(baseUrl: VideoIndexerUri);                  // Video Indexer URL
client.AddDefaultHeader("x-ms-client-request-id", string.Empty);
client.AddDefaultHeader("Ocp-Apim-Subscription-Key", apiKey);

var accessToken = GetToken();
if (accessToken == null)
{
    Console.Error.WriteLine("ERROR:アクセストークンの取得に失敗しました。");
    return;
}
Console.WriteLine(accessToken);

var videosJson = ListVideos();
if (videosJson == null)
{
    Console.Error.WriteLine("ERROR:ビデオ一覧の取得に失敗しました。");
    return;
}

//ビデオ情報の取得
var obj = JObject.Parse(videosJson);
var items = (JArray)obj["results"];
foreach( var item in items)
{
    var videoId = (string)item["id"];
    var videoTitle = (string)item["name"];
    var state = (string)item["state"];
    var progress = (string)item["processingProgress"];
    var lang = (string)item["sourceLanguage"];

    Console.WriteLine($"{state}({progress}), {videoId}:{videoTitle}");
    if (state.Equals("Processing")) continue;

    //解析情報の出力
    var index = VideoIndex(videoId, lang);
    if (index != null)
    {
        using var sw1 = new StreamWriter(@$"va_{videoId}.json");
        sw1.WriteLine(index);
    }

    //文字起こし内容の出力
    var caption = VideoCaptions(videoId, lang);
    if (caption != null)
    {
        using var sw2 = new StreamWriter($@"va_{videoId}.srt");
        sw2.WriteLine(caption);
    }
}

//VideoAnalyzerトークン取得
string? GetToken()
{
    Console.WriteLine("Access-Token.");
    var request = new RestRequest($"Auth/{location}/Accounts/{accountId}/AccessToken", Method.Get);
    request.AddParameter("allowEdit", "true", ParameterType.GetOrPost);
    var response = client.ExecuteAsync(request).Result;
    if (HttpStatusCode.OK != response.StatusCode)
    {
        Console.Error.WriteLine($"ERROR: {response.StatusCode}={response.Content}");
        return null;
    }
    var token = JsonConvert.DeserializeObject<string>(response.Content);
    return token;
}

// ビデオ一覧取得
string? ListVideos()
{
    Console.WriteLine("ListVideos.");
    var request = new RestRequest($"{location}/Accounts/{accountId}/Videos", Method.Get);
    request.AddParameter("accessToken", accessToken, ParameterType.GetOrPost);
    var response = client.ExecuteAsync(request).Result;
    if (HttpStatusCode.OK != response.StatusCode)
    {
        Console.Error.WriteLine($"ERROR: {response.StatusCode}={response.Content}");
        return null;
    }
    return response.Content;
}

//解析情報の取得
string? VideoIndex(string id, string lang)
{
    // リクエスト作成
    var request = new RestRequest($"{location}/Accounts/{accountId}/Videos/{id}/Index", Method.Get);
    request.AddParameter("language", lang, ParameterType.GetOrPost);
    request.AddParameter("accessToken", accessToken, ParameterType.GetOrPost);
    var response = client.ExecuteAsync(request).Result;
    if (HttpStatusCode.OK != response.StatusCode)
    {
        Console.Error.WriteLine($"ERROR: {response.StatusCode}={response.Content}");
        return null;
    }
    return response.Content;
}

//文字起こしデータ取得
string? VideoCaptions(string id, string lang)
{
    // リクエスト作成
    var request = new RestRequest($"{location}/Accounts/{accountId}/Videos/{id}/Captions", Method.Get);
    request.AddParameter("indexId", id, ParameterType.GetOrPost);
    request.AddParameter("format", "Srt", ParameterType.GetOrPost);     //Vtt/Ttml/Srt/Txt/Csv
    request.AddParameter("language", lang, ParameterType.GetOrPost);
    request.AddParameter("accessToken", accessToken, ParameterType.GetOrPost);

    var response = client.ExecuteAsync(request).Result;
    if (HttpStatusCode.OK != response.StatusCode)
    {
        Console.Error.WriteLine($"ERROR: {response.StatusCode}={response.Content}");
        return null;
    }
    return response.Content;
}

List Video

まずは、 List Video でビデオ一覧を JSON 形式で取得します。 この JSON データの中にステータス(state)と進捗(processingProgress)が入っているので、 ステータスの値をみて解析が終了しているのかを判定します。

項目 説明
state 'Processed'(完了) か 'Processing'(解析中)
processingProgress '100%' で完了

解析が終了すると、 ビデオID(id) を使って解析情報を取得することができるようになります。 List Video API の結果を JSON配列として扱えるように変換する必要があります。 JObject.Parse() を使って平文の JSON テキストを JObject に変換し、"results" の項目を JArray 配列にキャストします。

必要な個所を抜粋したソースは以下の通り。

var videosJson = ListVideos();
var obj = JObject.Parse(videosJson);
var items = (JArray)obj["results"];
foreach( var item in items)
{
    var videoId = (string)item["id"];
    var state = (string)item["state"];
    var progress = (string)item["processingProgress"];
}

ビデオ ID が取得できれば、後は必要な API を呼び出して解析データを取得するだけです。

ただし、1回のリクエストで取得できる件数の上限 (25件だったか) があるため、 解析したビデオが増えた場合、ページ制御が必要になります。 これは、API のパラメータで設定することができます。

Get Video Index

Video Analyzer で解析した結果を JSON 形式で取得するのですが、 ビデオによってはサイズが大きくなるため、拡張子 .json でファイルに保存しています。

そのまま中身を見てもインデントされてないため、読めないと思います。 Visual Studio Code で整形、または Linux の jq コマンド等を使って、 見やすい形式に変換して解読してください。

Get Video Captions

音声認識したテキストを取得するのですが、 こちらもサイズが大きくなるため、拡張子 .srt でファイルに保存します。 ファイル形式は選べるので、必要に応じてパラメータを変更してください。

この srt ファイルをビデオファイルと同じ名前に変更し、 同じフォルダの下に置くと、字幕として表示させることができます。

まとめ

今回は、Video Analyzer を C# から利用するための簡単なソースコードを紹介しました。 本当は、 List Video で取得した JSON の内容や、 Get Video Index で取得した JSON の内容を もう少し説明しようと思いましたが、説明が長くなったので、またの機会にしようと思います。

Video Analyzer を使ってみて思うのですが、実運用で使うには精度が不足していますが、 汎用 AI なので、仕方ないと思います。 これを一時解析として、次の AI 解析のインプットに使う選択はありだと思います。

実際に作ったアプリは、クライアントを WPF アプリで作成し、 サーバとは gRPC でファイルや DB 情報を HTTP/2 で送受信します。 また、クライアント側はビデオを流しながら解析結果を表示したり、 別の AI サービスの結果を表示したりと、かなり手が込んでいます。 サーバ側は、Web(Nginx)・AP(Kestrel)・DB(PostgreSQL) の三層構造で、 Docker ファイルで構築できるようになっています。 この辺りの話題も、いつか紹介していこうと思います。

では、皆さん、よい旅を。