VRSNSで撮った写真を自動でいい感じに選択して定期的にTwitterに投稿できるようにしてみた

こんにちは、[とうとう](https://twitter.com/__tou__tou)です。この記事は[XR好き開発合宿ブログリレー](https://iwaken.notion.site/XR-a2faaa2ebc7c4b5381d48e1d2aebd11e)2日目の記事です。2泊3日の合宿中に開発した内容を紹介していきます。
# 概要

VRSNSで撮った大量の写真の中からいい感じに写真を選んでTwitterに投稿するのって大変ですよね...それを自動で(ランダムに選ぶよりかは)いい感じに選んで投稿するシステムを作ってみました！


![スクリーンショット 2022-09-24 23.50.17.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1333433/a7cafc75-602d-0196-301a-7d7700687ba4.png)

仕組みとしては、写真を保存したフォルダを読み込む、できるだけ最近撮影した画像を選ぶ、[dHash](https://www.hackerfactor.com/blog/index.php?/archives/529-Kind-of-Like-That.html)で画像距離計算をして似ている画像は選ばない、API経由でTwitterに投稿する、定期実行にはWindows標準アプリのタスクスケジューラーを利用するといったものです。


VRChatで毎日増える大量の写真の中からいい感じに写真を選んで毎日投稿するのは手間なんですが、このシステムを使えば楽ができます。
実は、とうとうさんの[「最近の！」ツイート](https://twitter.com/search?q=from%3A__tou__tou%20%E6%9C%80%E8%BF%91%E3%81%AE%EF%BC%81&src=typed_query)は2021年4月頃から自動化されていて、最近の写真をランダムに選ぶという手法をとっていたのですが、それだと同時に似た画像を選んでしまいbot感が出てしまっていたので、今回の合宿で[dHash](https://www.hackerfactor.com/blog/index.php?/archives/529-Kind-of-Like-That.html)を用いた画像距離計算をし、似た画像を選ばないアルゴリズムを追加したという経緯です。

また、GitHub Actionsを利用してリリースページに成果物を置けるようにしました。テストを書いていなかったり、まだまだ機能追加できる部分があったりするのですが、少しづつ良くしていきたいです。リポジトリは[こちら](https://github.com/tou-tou/PickPicTweet)になります。

# 紹介スライド

<iframe src="https://docs.google.com/presentation/d/e/2PACX-1vTJNxkiP8A_Ux-liRFUpkSN1POERUO4CZ_vd23XIQUFpINoLEznHgq7plehHR5yfBz7UhvHR8nMAa4y/embed?start=false&loop=false&delayms=3000" frameborder="0" width="960" height="569" allowfullscreen="true" mozallowfullscreen="true" webkitallowfullscreen="true"></iframe>

# 誰向け
- PickPicTweetがどんな実装か知りたい
    - フォルダやファイル移動周りの処理について
    - dHashについて
    - Twitter APIの簡単な使い方について
- VRSNSで撮った写真を自動で投稿できるようにしたい
    - 今回の記事の場合、自力でTwitter開発者申請してRead/Write用のトークンを取得できること
- GitHub Actionsを利用して、.NETプロジェクトをWindows環境でビルドし、リリースページのアセットに特定のファイルを置くyamlファイルを参考にしたい
- Windows標準アプリのタスクスケジューラーの使い方を知りたい


# 機能
- 写真フォルダ配下を月別・日付別に割り振り直す
    - デフォルトでは機能OFF
    - 写真フォルダの構成を変えてしまうので利用は慎重に
- いい感じに写真を選ぶ
    - できるだけ最近撮影した写真を選びます
    - dHashを用いた画像類似度を計算し似ている写真は投稿写真から除かれます
    - 過去(100枚)に投稿した写真は取り除かれます
    - secretフォルダ直下の写真は選ばれない
        - 手動でsecretフォルダを作成し、その直下に選びたくない写真を予め入れておく必要があります
- 画像付きツイート
### 実行時オプション
- `--full-path "画像が含まれるフォルダのフルパス"`
  - デフォルトはVRChatの画像フォルダへのパス
- `--every-dir n`
  - 0:デフォルト値、何もしない 
  - 1:画像を月別のフォルダに振り分ける。一度実行するともとに戻せないので注意が必要。
  - 2:画像を月別フォルダ配下の日付別フォルダに振り分ける。一度実行するともとに戻せないので注意が必要。
- `--pic-pool-count n`
  - n枚の写真から4枚が選ばれる。デフォルトは100
- `--no-tweet n`
  - 0:ツイートする、デフォルト値
  - 1:ツイートしない


# 使い方

アプリケーションを使う場合は自己責任でお願いします。

### リリースページから実行ファイルをダウンロード
- [Twitter開発者プラットフォーム](https://developer.twitter.com/ja/docs/twitter-ads-api/getting-started) で開発者用トークンを申請して取得する。
    - Read/Write権限が必要です。
    - 参考記事：[【2022年度最新版】Twitter API 申請方法(Twitter v1.1 v2対応)](https://zenn.dev/eito_blog/articles/9a17a64ffa3c6b)
- [Releases · tou-tou/PickPicTweet](https://github.com/tou-tou/PickPicTweet/releases) から`PickPicTweet.exe`と`appsettings.json`をダウンロード
- `appsettings.json`に取得したトークンを設定し、実行ファイルと同じディレクトリに配置
- Windowsタスクスケジューラーに日時や実行ファイル、引数を指定する
  - [参考](###タスクスケジューラーに実行ファイルを登録)

### Visual Studio2022で実行ファイルをビルド

- [Twitter開発者プラットフォーム](https://developer.twitter.com/ja/docs/twitter-ads-api/getting-started)で開発者用トークンを申請して取得する
    - Read/Write権限が必要です。
    - 参考記事：[【2022年度最新版】Twitter API 申請方法(Twitter v1.1 v2対応)](https://zenn.dev/eito_blog/articles/9a17a64ffa3c6b)
- `appsettings_sample.json`を参考に`appsettings.json`を作成し取得したトークンを設定する
- Visual Studio2022でソリューションをビルドし、実行ファイルを生成する
    - .NET6.0のビルド環境です
    - [参考](###実行ファイルの生成)
- Windowsタスクスケジューラーに日時や実行ファイルを指定する
    - [参考](###タスクスケジューラーに実行ファイルを登録)


# 実装について
以下のような実装となっています。
- VRChatの画像フォルダ配下を日別フォルダに振り分ける
- いい感じに写真選ぶアルゴリズム
    1. 直近100枚の画像を時系列降順に並び替えたリストを作成
    1. リストから過去に投稿した画像を取り除く
    2. リストの前半から1枚(pic1)をランダムに選び、リストから除く
    3. pic1との画像距離が一定以下(画像類似度が一定以上)の画像をリストから除く
    4. リストの前半からランダムに1枚(pic2)を選ぶ
    5. 2-4を繰り返し同様にpic3,pic4を選ぶ。リストの要素数が0になるかpic4を選ぶかで終了
    6. 最大4枚の選ばれた画像パスをローカルテキストに保存
- TwitterAPIを利用して選ばれた画像をツイート
- 定期実行はWindows標準アプリのタスクスケジューラーで投稿間隔と実行ファイルを登録

## 画像を日別フォルダに振り分ける
画像作成時刻で月別または月別/日付別にフォルダを作成しそのフォルダ配下に画像を移動します。実装は以下です。

https://github.com/tou-tou/PickPicTweet/blob/main/PickPicTweet/PicEveryDay.cs

## いい感じに写真を選ぶアルゴリズム
(ランダムに選ぶよりかは)いい感じに写真を選ぶアルゴリズムですが、以下のような感じになっています。

1. 直近100枚ぐらいの画像のリスト`_orderedSet`を時系列降順に並び替え
2. `_orderedSet`の前半から1枚の画像(pic1)をランダムに選び、`_orderedSet`から除く
3. pic1との画像距離が一定以下(画像類似度が一定より大きい)の画像を`_orderdSet`から除く
4. `_orderedSet`の前半からランダムに1枚(pic2)を選ぶ
5. 3-4を繰り返し同様にpic3,pic4を選ぶ。`_orderedSet`の要素数が0になるかpic4を選ぶかで終了

実装は以下になっています。

https://github.com/tou-tou/PickPicTweet/blob/main/PickPicTweet/SelectBalancedPic.cs

### [dHash](https://www.hackerfactor.com/blog/index.php?/archives/529-Kind-of-Like-That.html)を用いた画像距離計算について

[dHash](https://www.hackerfactor.com/blog/index.php?/archives/529-Kind-of-Like-That.html)を用いた画像距離計算は以下のようなシンプルなアルゴリズムとなっています。

![スクリーンショット 2022-09-25 2.53.22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1333433/360f7b91-d22c-df6f-56a2-9d951895b371.png)

### ハッシュ値の比較計算
dHashの良さは画像を64bitのハッシュ値に変換することができ、2つの画像を表すハッシュ値の比較が`XOR`、`POPCNT`といった(おそらく)ハードウェア命令のレベルで実装されている関数を利用して計算するので高速なはずです。(そもそも高速にする必要があるのかみたいな話がありますが、それは置いてもらって...)

そして、`XOR`と`POPCNT`を用いたハッシュ値の比較計算は以下のようになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1333433/7f360622-7c16-fee0-d765-3c056263856d.png)

実際の実装は以下の実装でシンプルにかけて嬉しい気持ちになれます。
```c#:PicSimilality.cs
/// <summary>
/// ulong(uint64)のハミング距離[0,64]を計算
/// 二つの画像のハッシュ(uint64)をXORしてPopCount(いくつのビットが1かをカウントする)
/// XORとPopCountはどちらもプリミティブな命令なので高速に計算できる
/// ref:https://ja.wikipedia.org/wiki/%E3%83%8F%E3%83%9F%E3%83%B3%E3%82%B0%E8%B7%9D%E9%9B%A2
/// </summary>
/// <param name="img1"></param>
/// <param name="img2"></param>
/// <returns></returns>
public int ComputeHammingDistance(Bitmap img1,Bitmap img2)
{
    // 画像サイズを小さくして、グレースケールに変換しハッシュ化
    var hash1 = ComputeAdjacentPixelDiff(ConvertGray(ReduceSize(img1)));
    var hash2 = ComputeAdjacentPixelDiff(ConvertGray(ReduceSize(img2)));
    // ハミング距離の計算
    return BitOperations.PopCount(hash1^hash2);
}

```
https://github.com/tou-tou/PickPicTweet/blob/main/PickPicTweet/PicSimilarity.cs



## TwitterAPIを使用して画像ツイート
#### [Twitter開発者プラットフォーム](https://developer.twitter.com/ja/docs/twitter-ads-api/getting-started)で開発者用トークンを申請して取得
こちらの記事[【2022年度最新版】Twitter API 申請方法(Twitter v1.1 v2対応)](https://zenn.dev/eito_blog/articles/9a17a64ffa3c6b)などを参考にしながら開発者用トークンを申請して取得してください。Read/Write権限が必要なのでご注意ください。
#### トークンを`appsettings.json`に保存
取得したトークンをプロジェクト内の`PickPicTweet/appsettings.json`に設定します。

```json:appsettings.json
{
  "ConsumerKey": "",
  "ConsumerKeySecret": "",
  "AccessToken": "",
  "AccessTokenSecret": ""
}
```
#### `PickPickTweet.csproj`ファイルに`appsettings.json`がBuildに含まれるように下記を追加

`PickPicTweet/PickPickTweet.csproj`ファイルに`appsettings.json`がBuildに含まれるように下記を追加
```csproj:PickPicTweet.csproj
<ItemGroup>
    <None Update="appsettings.json"> 
        <CopyToOutputDirectory>Always</CopyToOutputDirectory>
    </None>
</ItemGroup>
```

https://github.com/tou-tou/PickPicTweet/blob/main/PickPicTweet/PickPicTweet.csproj

#### 画像付きツイート
画像をアップロードしてメディアidを取得し、画像付きツイートをします。
```csharp:Twitter.cs
/// <summary>
/// 画像をアップロードしてメディアidを取得
/// </summary>
/// <param name="path"></param>
/// <returns></returns>
private long UploadImage(string path)
{
    MediaUploadResult result = Token.Media.Upload(media: new FileInfo(path));
    return result.MediaId;
}

/// <summary>
/// 最大4枚の画像付きツイート
/// 画像がない場合はテキストのみツイート
/// </summary>
/// <param name="text"></param>
/// <param name="paths"></param>
public void ImageTweet(string text, List<string> paths)
{
    // 画像をアップロードして返ってきたmedia_idを追加する
    List<long> mediaIds = paths.Select(UploadImage).ToList();
    Token.Statuses.Update(new
    {
        status = text,
        media_ids = mediaIds
    });
}


```
https://github.com/tou-tou/PickPicTweet/blob/main/PickPicTweet/Twitter.cs

## タスクスケジューラで定期実行
### 実行ファイルの生成
`Visual Studio 2022`や`Rider`でプロジェクトのビルドを行い、実行ファイルを生成します。ビルド環境が`.NET6.0`なので注意が必要です。

[リポジトリ](https://github.com/tou-tou/PickPicTweet)からcloneまたはダウンロードしてきたら、`Visual Studio 2022`でそのディレクトリを選んで開きます。ソリューションとして認識されない場合は、フォルダービューから`PickPicTweet.sln`をクリックするとソリューションとして認識されるはずです。そのあと、`appsettings.json`にトークンを設定し、下図のようにビルドすれば実行ファイルを生成できます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1333433/011ee18f-1d7f-3ff8-ea0f-ad3acc6aaf43.png)


または、[Releases · tou-tou/PickPicTweet](https://github.com/tou-tou/PickPicTweet/releases)から拾ってくる方法もありますが、この場合はTwitter用のトークンを設定した`appsettings.json`と実行ファイルを同じディレクトリに配置してください。

### タスクスケジューラーに実行ファイルを登録
Windowsの検索窓から「タスクスケジューラ」で検索し起動


基本タスクの作成します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1333433/ba2fd721-0239-f7ba-89b0-b76c9a1318f8.png)

適当な名前をつけます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1333433/c816714b-129c-4634-3430-de6d6365a8fc.png)

トリガーを毎週で指定し開始時期や実行したい日付を選びます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1333433/359b1074-a49b-3e2a-9788-10f9bbb8d44d.png)

実行ファイルを登録し(必要であれば)引数を指定します。下図では`--every-dir 2`を指定しますが、このオプションは画像フォルダ配下を月別/日付別フォルダに変えてしまい、一度実行すると元に戻せないので注意が必要です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1333433/7773d627-d195-fadb-3608-ddab56724f2c.png)

下図は月別/日付別にファイルが割り振られた様子
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1333433/479504d9-fb93-78c1-eae1-6dc42c34b1ca.png)
# GihHub Actionsでのリリース
mainブランチにpushしたら、.NETプロジェクトをWindows環境上でビルドして、実行ファイルや設定ファイルのテンプレートをリリースページのAssetとしておくようにしました。

https://github.com/tou-tou/PickPicTweet/releases

yamlファイルはこんな感じです。

https://github.com/tou-tou/PickPicTweet/blob/main/.github/workflows/build.yaml


# 今後の課題とか
- 単体テストを書く
- VRSNSのログとかを使ってアバターやワールドの種類を画像選択に組み込む
- 撮影した写真の中で投稿したくない画像（ネタバレ系とか取り扱い注意な画像）を投稿しないようにする
- Twitter連携できるようにして誰でも使えるようにする

# まとめ

正直なところ、テストを書いていなかったり、Twtter開発者申請が必要だったり、明らかに撮影ミスした写真が選ばれたりするなど、使い勝手がよくない状態でリリースしたり記事を書いたりするのには抵抗感があったのですが、いつまでたっても公開しないのもよくないので、とりあえず公開してみることにしました。この記事がだれかのお役にたてば幸いです。

# 謝辞
また、最後になりますが、今回の開発合宿では多くの支援者の皆様にサポートしていただきました[^1]。楽しく開発できたり、メンバーとの交流ができたりでとても楽しかったです。本当にありがとうございました。

[^1]:[【支援求む】XR好き学生14名に2泊3日温泉開発合宿をプレゼントしたい (支援者30名・募集終了)｜イワケン｜note](https://note.com/iwaken71/n/n006388a64eb8)