からっぽのしょこ

読んだら書く!書いたら読む!同じ事は二度調べ(たく)ない

rtweetでツイート画像を集めてmagickでアニメーションを作成したい

はじめに

 この記事は「R Advent Calendar 2022」の10日目の記事です。

 rtweetパッケージを利用して収集したツイート画像をmagickパッケージを利用してアニメーションにします。

【目次】

したいこと

 2022年12月10日(この記事の投稿日)現在、モーニング娘。'22には13名のメンバーが在籍しています。その中の1人に、加賀楓さんという方がいます。出身は東京都ですが名前が同じということをきっかけに色々な活動を経て、石川県加賀市の加賀温泉郷の観光大使をされています。
 詳しくはこちらを覗いてみてください。

kagakaedeonsenkyo.com

 例年秋冬頃に、加賀温泉郷のプロモーション用のポスター広告が首都圏のJRの駅に1週間ほど掲載されます。百数十枚のポスターが百二十駅ほどに掲載されます。
 加賀楓オタクの方々は期間中に全てのポスターを見付けようと奮闘します。ポスターを発見すると、ポスターの写真と駅名にハッシュタグ「貼ろうプロジェクト」を付けてツイートします。

 掲載駅や掲載数をスプレッドシートにまとめていき、みんなでコンプリートを目指します。
 ちなみに、このツイートの方は加賀温泉郷のプロモーションをされている方で、掲載期間の後半になると掲載数などの情報を教えてくれます。

 私はというと、関西在住なので探しに行くことはできず、土地勘もなくポスター探しには協力できません。しかし私はR使いなので、Rを使って、ツイートされたポスター写真を収集して動画にまとめることにしました。
 過去3年分の動画がこちらです。

・2022年10月

・2021年8月

・2020年3月

 毎回試行錯誤して少しずつレベルアップしました。
 以上が企画説明です。言うなればhello! pRojectです。はい。

 さてこの記事では、ツイート収集と動画作成の方法を解説します。

ツイート画像のアニメーションの作成

 rtweetパッケージを利用して特定の単語やハッシュタグを含むツイートを収集し、ツイートに含まれる画像データをダウンロードします。集めた画像にggtextパッケージを利用してラベル付けして(文字列を書き込んで)、magickパッケージを利用してアニメーションを作成します。

 利用するパッケージを読み込みます。

# 利用パッケージ
library(tidyverse)
library(rtweet)
library(magick)
library(ggtext)

 この記事では、パッケージ名::関数名()の記法を使うので、パッケージを読み込む必要はありません。
 また、ネイティブパイプ演算子|>を使っています。magrittrパッケージのパイプ演算子%>%に置き換えても処理できますが、その場合はmagrittrを読み込む必要があります。

ツイート画像の収集

 まずは、指定したハッシュタグを含むツイートを収集して、ツイートに含まれる画像を収集します。ツイートに含まれる画像のスクレイピングについては「Rでツイート画像を取得する - からっぽのしょこ」を参照してください。

 ハッシュタグや単語を指定してツイートを収集します。

# ツイートを収集
tw_origin_df <- rtweet::search_tweets("#貼ろうプロジェクト", n = 10000, include_rts = FALSE)

 search_tweets()で指定した文字列を含むツイートを収集できます。

 指定した日付のツイートを抽出します。

# 日付を指定
date_str <- "20221024"

# 1日分のツイートを抽出
tw_jst_df <- tw_origin_df |> 
  dplyr::mutate(
    created_at = lubridate::as_datetime(created_at, tz = "Asia/Tokyo")
  ) |> # 日本標準時に変換
  dplyr::filter(
    created_at >= lubridate::as_datetime(date_str, tz = "Asia/Tokyo"), # から
    created_at < (lubridate::as_datetime(date_str, tz = "Asia/Tokyo") + lubridate::days(1)) # まで
  ) # 期間内のデータを抽出
tw_jst_df[1:5, c("created_at", "hashtags")]; nrow(tw_jst_df)
##            created_at           hashtags
## 1 2022-10-24 20:15:21 貼ろうプロジェクト
## 2 2022-10-24 19:27:31 貼ろうプロジェクト
## 3 2022-10-24 19:58:19 貼ろうプロジェクト
## 4 2022-10-24 19:13:43 貼ろうプロジェクト
## 5 2022-10-24 21:34:37 貼ろうプロジェクト
## [1] 141

 収集したツイートの投稿日時は協定世界時(UTC)なので、日本標準時(JST)に変換する必要があります。

 手打ちで用意した駅名一覧を読み込みます。

# 駅名リストを読み込み
ekimei_df <- readr::read_csv(
  file = ".ekimei.csv", 
  locale = readr::locale(encoding = "utf8"), 
  col_types = readr::cols(駅名 = "c", えきめい = "c", ekimei = "c")
)
ekimei_df
## # A tibble: 113 × 3
##    駅名     えきめい     ekimei   
##    <chr>    <chr>        <chr>    
##  1 秋葉原   あきはばら   akihabara
##  2 阿佐ヶ谷 あさがや     <NA>     
##  3 浅草橋   あさくさばし asakusa  
##  4 熱海     あたみ       atami    
##  5 我孫子   あびこ       abiko    
##  6 飯田橋   いいだばし   <NA>     
##  7 池袋     いけぶくろ   ikebukuro
##  8 板橋     いたばし     <NA>     
##  9 市ヶ谷   いちがや     ichigaya 
## 10 市川     いちかわ     <NA>     
## # … with 103 more rows

 (アルファベット表記が欠損しているのは途中で使わなくなって打ち込むのを止めたからです。)

 画像付きのツイートを抽出します。

# 画像付きツイートを抽出
tw_station_df <- tw_jst_df |> 
  dplyr::select(created_at, text, ext_media_url, status_id, screen_name, user_id) |> 
  dplyr::arrange(created_at) |> # 投稿が早い順に並べ替え
  tidyr::unnest(ext_media_url) |> # 複数画像ツイートのネストを展開
  dplyr::filter(!is.na(ext_media_url)) # 画像付きツイートを抽出
tw_station_df[, c("created_at", "ext_media_url")]
## # A tibble: 41 × 2
##    created_at          ext_media_url                                            
##    <dttm>              <chr>                                                    
##  1 2022-10-24 02:00:15 http://pbs.twimg.com/media/FfxPKOUacAADmDt.jpg           
##  2 2022-10-24 02:00:15 http://pbs.twimg.com/media/FfxPKOVagAQYDBS.jpg           
##  3 2022-10-24 02:00:15 http://pbs.twimg.com/media/FfxPKOYaMAAQVbF.jpg           
##  4 2022-10-24 04:42:27 http://pbs.twimg.com/ext_tw_video_thumb/1584268488918454…
##  5 2022-10-24 04:52:31 http://pbs.twimg.com/ext_tw_video_thumb/1584271144772722…
##  6 2022-10-24 10:13:54 http://pbs.twimg.com/media/FfzAGviaMAEdvPM.jpg           
##  7 2022-10-24 10:47:28 http://pbs.twimg.com/media/FfzH0GxaEAA6CPn.jpg           
##  8 2022-10-24 10:47:28 http://pbs.twimg.com/media/FfzH0HFacAAgBS5.jpg           
##  9 2022-10-24 13:02:35 http://pbs.twimg.com/media/FfzmwbCUcAAvpSd.jpg           
## 10 2022-10-24 13:02:35 http://pbs.twimg.com/media/FfzmwbKUoAAj5Yk.jpg           
## # … with 31 more rows

 画像urlはext_media_url列に(複数画像ツイートの場合は)ネストされた状態で格納されているので、unnest()で展開します。

 リストの駅名を含むツイートテキストを検索します。

# 駅名を含むツイートを検索
for(i in 1:nrow(ekimei_df)) {
  # 駅名を取得
  station_name <- ekimei_df[["駅名"]][i]
  
  # 駅名列を追加
  tw_station_df <- tw_station_df |> 
    dplyr::mutate(!!station_name := stringr::str_detect(text, pattern = station_name))
}
tw_station_df[, c(1, 7, 8, 9, 10)]
## # A tibble: 41 × 5
##    created_at          秋葉原 阿佐ヶ谷 浅草橋 熱海 
##    <dttm>              <lgl>  <lgl>    <lgl>  <lgl>
##  1 2022-10-24 02:00:15 FALSE  FALSE    FALSE  FALSE
##  2 2022-10-24 02:00:15 FALSE  FALSE    FALSE  FALSE
##  3 2022-10-24 02:00:15 FALSE  FALSE    FALSE  FALSE
##  4 2022-10-24 04:42:27 FALSE  FALSE    FALSE  FALSE
##  5 2022-10-24 04:52:31 FALSE  FALSE    FALSE  FALSE
##  6 2022-10-24 10:13:54 FALSE  FALSE    FALSE  FALSE
##  7 2022-10-24 10:47:28 FALSE  FALSE    FALSE  FALSE
##  8 2022-10-24 10:47:28 FALSE  FALSE    FALSE  FALSE
##  9 2022-10-24 13:02:35 FALSE  TRUE     FALSE  FALSE
## 10 2022-10-24 13:02:35 FALSE  TRUE     FALSE  FALSE
## # … with 31 more rows

 セイウチ演算子:=を使って各駅名の列を作成します。ツイートテキスト(text列)にその駅名(station_name)を含む場合はTRUE、含まない場合はFALSEになります。

 駅名列を1つの列にまとめて、TRUEの行を抽出します。

# 駅名を含むツイートを抽出
tw_photo_df <- tw_station_df |> 
  tidyr::pivot_longer(
    cols = !c(created_at, text, ext_media_url, status_id, screen_name, user_id), 
    names_to = "station", 
    values_to = "flag" 
  ) |> # 駅名列をまとめる
  dplyr::filter(flag == TRUE) |> # 駅名を含むツイートを抽出
  dplyr::arrange(created_at) |> # 投稿が早い順に並べ替え
  dplyr::group_by(status_id) |> # 番号付け用にグループ化
  dplyr::mutate(num = dplyr::row_number()) |> # 同一ツイートを番号付け
  dplyr::ungroup() # グループ化を解除
tw_photo_df[, c("created_at", "station", "flag")]
## # A tibble: 43 × 3
##    created_at          station  flag 
##    <dttm>              <chr>    <lgl>
##  1 2022-10-24 10:13:54 町田     TRUE 
##  2 2022-10-24 10:47:28 町田     TRUE 
##  3 2022-10-24 10:47:28 町田     TRUE 
##  4 2022-10-24 13:02:35 阿佐ヶ谷 TRUE 
##  5 2022-10-24 13:02:35 高円寺   TRUE 
##  6 2022-10-24 13:02:35 中野     TRUE 
##  7 2022-10-24 13:02:35 阿佐ヶ谷 TRUE 
##  8 2022-10-24 13:02:35 高円寺   TRUE 
##  9 2022-10-24 13:02:35 中野     TRUE 
## 10 2022-10-24 13:02:35 阿佐ヶ谷 TRUE 
## # … with 33 more rows

 ここまでで、駅名と画像を含むツイートを取り出せました。
 ただし現状だと、例えば「東中野」に「中野」も引っかかってしまいます。また、複数の駅名が含まれる場合も行(データ)が重複します。

 画像をダウンロードして、駅名ラベルを付けて保存します。

# 保存先フォルダパスを指定
dir_path <- "tmp_folder"

# 画像サイズの上限値を指定
max_w <- 800
max_h <- 600

# 画像数を取得
photo_num <- nrow(tw_photo_df)

# ダミーのデータフレームを作成
dummy_df <- tidyr::tibble(v = 0)

# 画像ごとに処理
for(i in 1:photo_num) {
  # 情報を取得
  photo_url    <- tw_photo_df[["ext_media_url"]][i] # 画像url
  photo_n      <- tw_photo_df[["num"]][i] |> 
    stringr::str_pad(width = 2, pad = "0") # 画像番号
  tw_date      <- tw_photo_df[["created_at"]][i] |> 
    lubridate::date() |> 
    stringr::str_remove_all(pattern = "-") # ツイートの日付
  tw_datetime  <- tw_photo_df[["created_at"]][i] |> 
    stringr::str_replace(pattern = " ", replacement = "_") |> 
    stringr::str_remove_all(pattern = "-|:") # ツイートの日時
  tw_id        <- tw_photo_df[["status_id"]][i] # ツイートのユニークID
  tw_user      <- tw_photo_df[["screen_name"]][i] # ツイートのユーザー名
  station_name <- tw_photo_df[["station"]][i] |> 
    (\(.) {paste0(., "駅")})() # 駅名
  
  # ファイル名を作成
  file_name <- paste0(tw_datetime, "_", tw_id, "_", photo_n, ".png")

  # 画像をダウンロード
  photo_data <- magick::image_read(path = photo_url)

  # 画像サイズを整形
  resize_data <- magick::image_scale(image = photo_data, geometry = paste0(max_w, "x", max_h))
  
  # 画像サイズを取得
  resize_info_df <- magick::image_info(image = resize_data)
  resize_w <- resize_info_df[["width"]]
  resize_h <- resize_info_df[["height"]]
  
  # 駅名ラベルを書き込み
  g <- ggplot2::ggplot(data = dummy_df) + # ダミーデータを設定
    ggplot2::annotation_raster(raster = resize_data, xmin = -Inf, xmax = Inf, ymin = -Inf, ymax = Inf) + # 画像
    ggtext::geom_textbox(label = station_name, x = 0, y = 0, hjust = 0, vjust = 0, 
                         text.color = "red", box.color = "red", fill = "white", alpha = 0.8, 
                         size = 10, box.size = 1, width = ggplot2::unit(3, units = "inches"), 
                         box.padding = ggplot2::unit(c(10.5, 5.5, 10.5, 10.5), units = "pt"), 
                         box.r = ggplot2::unit(1, units = "lines")) + # 駅名ラベル
    ggplot2::lims(x = c(0, 1), y = c(0, 1)) + # ラベル位置を調整
    ggplot2::theme_void() # 軸を非表示
  
  # ラベル付き画像を書き出し
  ggplot2::ggsave(
    file = paste0(dir_path, "/", file_name), plot = g, 
    width = resize_w/100, height = resize_h/100, dpi = 100
  )
  
  # おまじない
  Sys.sleep(2)
}

 画像サイズは様々だったりオリジナルサイズだと重くなったりするので、image_scale()である程度整えておきます。サイズ整形については「magick::image_scale関数による画像変形 - からっぽのしょこ」を参照してください。
 geom_textbox()を使って画像に駅名ラベルを書き込みます。ラベル付けについては「ggtext::geom_textbox関数によるラベル付け - からっぽのしょこ」を参照してください。
 ラベル付けしたポスター写真をggsave()で保存します。

 以上で、アニメーションに利用するツイート画像を収集できました。ただし、駅名が重複した場合は、ポスター写真かどうかを目grepする必要があります。

動画作成

 次は、集めた画像のサイズを統一するように加工して、アニメーションを作成します。詳しくは「magickパッケージによるアニメーションの作成 - からっぽのしょこ」を参照してください。

 各ラベル付き写真を背景用の画像に重ねることで、画像サイズを統一します。

# ファイル名を取得
file_name_vec <- list.files(dir_path)

# ファイルパスを作成
file_path_vec <- paste0(dir_path, "/", file_name_vec)

# サイズ統一用の背景画像を読み込み
background_data <- magick::image_read(path = "background.png") |> 
  magick::image_scale(geometry = paste0(max_w, "x", max_h, "!"))

# 画像ごとに背景を挿入
for(i in seq_along(file_path_vec)) {
  # i番目のファイルパスを抽出
  file_path <- file_path_vec[i]
  
  # 画像を読み込み
  pic_data <- magick::image_read(path = file_path)
  
  if(i == 1) {
    # 背景画像に重ねる
    pic_data_vec <- magick::image_mosaic(image = c(background_data, pic_data))
  } else {
    # 背景画像に重ねる
    pic_back_data <- magick::image_mosaic(image = c(background_data, pic_data))
    
    # ベクトルに格納
    pic_data_vec <- c(pic_data_vec, pic_back_data)
  }
}

 背景用の画像を用意しておき、image_mosaic()で写真データと重ねて、pic_data_vecに格納していきます。

 アニメーション(gifファイルまたはmp4ファイル)を作成します。

# gif画像を保存
magick::image_write_gif(image = pic_data_vec, path = "kaedy.gif", delay = 0.1)

# mp4動画を保存
magick::image_write_video(image = pic_data_vec, path = "kaedy.mp4", framerate = 1)

目grepしていない動画

 image_write_gif()でgifファイル、image_write_video()でmp4ファイルに変換できます。delay引数は1フレーム当たりの表示秒数、framerate引数は1秒当たりのフレーム数を指定できます。1秒当たり10フレームとするのであれば、delay引数に1/10framerate引数に10を指定します。

 以上で、ツイート画像のアニメーションを作成できました。
 Enjoy!

参考

おわりに

 書き残さなくてはと思ってからはや幾年、何度も継ぎ接ぎして使ってきたスクリプトをようやくまとめられました。これで何度ポスター企画があっても大丈夫なので、いつでもどうぞ!
 解説を書いていたらどうにも話がとっ散らかったので、処理の目的ごとに別の記事にしたので、詳細が気になった方はリンクしてある記事も読んでみてください。

 そして、2022年12月10日はモーニング娘。'22の加賀楓さんの卒業の日です。

 かえでぃーがリーダーでエースなモーニング娘。を見たかったという思いはまだあるけど、自己プロデュースするかえでぃーを見てみたい。卒業おめでとうございます!