はじめに
恥ずかしながら私、Twitterの下書きが800件ほどたまっています。
私は1日数ツイートをするかしないかくらいのライトなツイッターユーザーなのですが、ツイート内容を書いたあとすぐには呟かずに一度下書きに保存する癖があります。これはその場のノリでツイートしてしまったり、誤った情報を発信してしまうのを防ぐには有効なのですが、デメリットもあります。下書きがどんどん溜まってしまうのです。下書きを書いたまま結局呟かずじまいのツイートも多く、気付けば「下書き」欄をいくらスクロールしても下までたどり着かないほどになってしまいました。
Twitterの下書きはスマホなどのデバイスに保存されてデバイス間では共有されず、例えばスマホを新しくした時などに下書きの履歴を引き継ぐことができません。デバイスに保存されているデータを取り出せないかと試みたことはありましたが、どこにデータが保存されているのか見つけることができませんでした。
例えツイートしなくても、下書きには過去の自分がどのようなことを考えていたのか、どんな出来事があったのかなど、自分に関する履歴が詰まった貴重な情報です。ある日スマホを水没させてそのデータが失われてしまったらそれは自分にとって大きな損失です。私は早急に下書きを取り出してバックアップする必要がありました。
ネットで雑に「Twitter 下書き 取り出す」などと検索するとdraft-tweets APIに関するドキュメントを発見しました。しかしどうやらこれは広告用のパブリックなアカウントに関連する下書きの話であり、一般ユーザーの下書きにアクセスできるようなAPIはなさそうでした。
友人に相談すると「一回全部ツイートしちゃえばAPIで取得できるよ」と言われました。
違うんです。ツイートしたくないから下書きなんです。
「メモ帳か何かに下書きを書くようにして、ツイートするときにそれをコピペすればいいじゃん」という声も聞こえてきます。
それではダメなんです。メモ帳にツイートは書けません。根本的に違うのです。その場限りの刹那の感情はツイッターの下書きにしか存在できないのです。
誰かわかってくれる人はいないものでしょうか...。自分に共感してくれる人はきっとたくさんいると信じ、同じ悩みを抱えた皆さんのために私は立ち上がりました。
方針
どうにかして下書きを取り出せないかと考えた結果ある方法を思いつきました。それはOCRを使う方法です。OCR(Optical Character Recognition/Reader、光学的文字認識) とは一言で言えば 画像から文字を読み取る技術です。画像認識の分野では古くからある技術ですが、近年の機械学習の発展によってその精度は年々向上しています。
下書きのテキストデータが得られないなら、あえて下書き画面を一度画像として撮影し、そこから文字を認識することでテキストデータに変換できないかと考えました。
ただ、精度が高まっているとはいえ、OCRは完璧ではありません。OCRを使ったことがある方であれば、誤認識を減らして意味の通る文章を取得するのはかなり大変ということをご理解いただけると思います。
しかし、Twitterの下書き欄には以下のような特徴があります。
- 文字のフォントが一定である
- 文字が水平に整列していて、文章幅や行間が一定である
- スマホ画面をスクショすれば、光や角度など撮影条件によるブレが全くない
これらはOCRする上で非常に有利です。というよりほとんど理想的な条件と言って差し支えありません(そもそも電子的な文字情報は普通は簡単に取り出せるので、OCRが求められる場面はほとんどなく、今回の状況が特殊なのだとも言えます)。
具体的には以下のようなプロセスを踏みます。
- Twitterの下書き画面を上から下までスクロールする動画を撮影する
- 動画を十分短いスパンで画像として切り出す
- 切り出した各画像に対してOCRをかけ、文章を読み取る
- それらをつなげて下書きリストを作る
実装
以下では、スマホ画面を録画した動画を入力とし、下書きの文字列リストを返してくれるようなコードを書いていきます。完全なコードはGithubに載せているのでそちらを使って動かすことができます。
動画を切り出す
まず、Twitterの下書き欄を上から下にスクロールする動画を撮影します。そしてそれを一定の間隔で画像として切り出します。全ての下書きを含むためには、隣接するフレームで多少の重複があるように、動画のスクロール速度を見ながら切り出しのインターバルを定める必要があります。
画像の読み書きにはOpenCVを使いました(こちらの記事を参照しました)。動画をinterval_sec
秒ごとに切り出してimg_folder
に保存します。
なお、スマホを想定すると、画像の上端と下端は不要な情報が混ざっている可能性が高いと思われるので除去しています。
def split_video(video_path: str, img_folder: str, interval_sec: float):
cap = cv2.VideoCapture(video_path)
if not cap.isOpened():
return
# 何frameごとにcaptureするか
interval = int(cap.get(cv2.CAP_PROP_FPS) * interval_sec)
idx = 0
while cap.isOpened():
ret, frame = cap.read()
if not ret:
break
if idx % interval == 0:
height = frame.shape[0]
# 画像の上端と下端1/8を除去しておく
frame = frame[int(height * 1/8):int(height * 7/8)]
filled_second = str(idx // interval).zfill(4)
cv2.imwrite(f"{img_folder}/{filled_second}.png", frame)
idx += 1
画像から文字を読み取る(OCR)
次はOCRのパートです。今回は、tesseractとGoogle Cloud Vision APIの2つを試してみました。
tesseract
tesseractはオープンソースのOCRで非常に導入が簡単なので、プロトタイプを作るにはもってこいです。導入の仕方はこちらなどを参考にしました。
def read_strings_tesseract(img_path: str, draft_list: List[str],
margin_ratio: float):
import pyocr
import pyocr.builders
import pyocr.tesseract
tesseract = pyocr.tesseract
# tesseractを使って行ごとに文字列を認識
img = Image.open(img_path)
res = tesseract.image_to_string(
img,
lang='jpn',
builder=pyocr.builders.LineBoxBuilder()
)
prev_pos = -100
current_draft = ''
for i, box in enumerate(res):
line_height = box.position[1][1] - box.position[0][1]
# 直前に認識した文字列の下端(prev_pos)と、今回の文字列の上端が
# margin_ratio * line_heightより大きかった場合、別ツイートと見做す
# append_stringを実行し、current_draftを初期化
if prev_pos + margin_ratio * line_height <= box.position[0][1]:
draft_list = append_string(draft_list, current_draft)
current_draft = ''
# current_draftに認識した行を追加し、prev_posを更新
current_draft = current_draft + box.content
prev_pos = box.position[1][1]
draft_list = append_string(draft_list, current_draft)
return draft_list
関数の前半部分、OCRによる文字読み取りでは、pythonでtesseractを簡単に扱えるwrapperであるpyocrを使っています。builder(読み取り方式のようなもの)は色々あるようですが、ここでは一行ごとにまとめてテキストを読むことのできるLineBoxBuilder()
を使いました。
後半部分は後処理です。ここでは行ごとに読み取った結果をツイート単位に結合しています。LineBoxBuilder()
はposition
要素で読み取った文字の位置(ピクセル)を取得できます。それを利用して、直前の行との間隔が狭ければ同一ツイートの続き、広ければ次の新たなツイートという判定をします。margin_ratio
変数は、ツイート間の幅が文字幅に対してどの程度とするかを定義するパラメータです(デフォルトでは1.5にしています)。
これによって1つのツイートを取得したら、それをappend_string
関数で整形してdraft_list
に追加します。append_string
関数については後ほど説明します。これを切り出した画像それぞれに順に適用していくことで、全ての下書きをdraft_list
に追加することができます。
Google Cloud Vision API
tesseractは導入が簡単ですが誤認識もちらほらあるので、もう少し高精度なOCRが欲しくなりました。Google Cloud Vision APIは無料で試せるOCRの中ではかなり性能が良い方らしいので、こちらも試してみました。
利用にはAPI設定が必要だったので、以下を参考に設定しました。
クイックスタート: Vision API を設定する
Google CloudのCloud Vision APIで画像から日本語の文字抽出をしてみた
また、OCRは以下を参考に実装しました(1000リクエスト以上送ると課金が必要になるので注意が必要です)。
高密度ドキュメントのテキスト検出のチュートリアル
def read_strings_google_api(img_path: str, draft_list: List[str],
margin_ratio: float):
from google.cloud import vision
import io
# 画像を読み込んでリクエストを投げる
client = vision.ImageAnnotatorClient()
with io.open(img_path, "rb") as image_file:
content = image_file.read()
img = vision.Image(content=content)
response = client.document_text_detection(image=img)
# レスポンスからテキストデータを抽出
for page in response.full_text_annotation.pages:
# blocks内のblockは大抵1つのみ
for block in page.blocks:
prev_pos = -100
current_draft = ''
# paragraphは段落または行ごとにまとまっていることが多い
# (tweet単位にはなっていない)
for paragraph in block.paragraphs:
bbox = paragraph.words[0].bounding_box.vertices
upper = min([b.y for b in bbox])
lower = max([b.y for b in bbox])
line_height = lower - upper
# 直前に認識した文字列の下端(prev_pos)と、今回の文字列の上端が
# margin_ratio * line_heightより大きかった場合、別ツイートと見做す
# append_stringを実行し、current_draftを初期化
if prev_pos + margin_ratio * line_height <= upper:
draft_list = append_string(draft_list, current_draft)
current_draft = ''
# wordsに単語が入っているので、それを逐次結合する
# prev_posも逐次更新する
for word in paragraph.words:
lower = max([b.y for b in word.bounding_box.vertices])
prev_pos = max(prev_pos, lower)
current_draft += ''.join([
symbol.text for symbol in word.symbols
])
draft_list = append_string(draft_list, current_draft)
return draft_list
Google Cloud Vision APIに画像をPOSTすると、読み取られたテキストデータが返ってきます。
responseはpages
→blocks
→paragraphs
→words
→symbols
という階層構造になっており、文章のひとまとまりの情報は主にparagraph
に含まれています。ただしparagraph
は文や行ごとに括られており、それのみで1ツイート分には相当しないことが多いので、こちらも適切にツイートを結合していく必要があります。読み取った文字の位置情報はbounding_box.vertices
から取得できるため、tesseractのときとほぼ同様の方法で、文章間の幅を考慮して結合しツイートを取得しました。
読み取り結果を統合する
ここではappend_list
関数について説明します。
ここまで動画を何枚かの画像に切り出し、それぞれにOCRをかけることで下書きツイートをdraft_list
というリストに追加してきました。
しかし、下書きツイートを全て保存できるよう画像を切り出すスパンを短めに設定したため、読み取ったツイートの中には重複があると考えられます。
完全一致する文章を削除するのは簡単ですが、
- OCRの読み取りミスによって文章が一部異なってしまう場合
- ツイートが画面の上部や下部で見切れていたことで、全体の一部分しか読み取れない場合(下の画像のような感じ)
などまで考慮すると、ツイート一致判定は簡単ではありません。
よってここでは、draft_list
にツイートを追加する際にレーベンシュタイン距離による一致判定を行うことにしました。レーベンシュタイン距離(Levenshtein distance) とは二つの文字列がどの程度異なっているかを示す距離の一種で、編集距離とも呼ばれるものです。有名なアルゴリズムなので詳細は割愛しますが、例えばこちらの実装はシンプルでわかりやすかったです。
さて、append_string
関数の実装ですが、新たな下書きツイートnew_str
をdraft_list
に追加する際、重複の可能性があるのは高々直近のいくつかのツイートに限られると思うので、今回はdraft_list
の最後search_num
個(今回は10個)のツイートとの重複を調べます。
新しいツイートnew_str
と既存のツイートの一つs
について、どちらかは見切れていない完全な文字列が取得できていると仮定します。長い方をs1
、短い方をs2
とすると、両者が同じツイートの全体または一部だったなら、長い方であるs1
の方を採用したいことになります。
s1
とs2
の一致判定はcheck_duplicate
関数によって行います。
もしs1
の一部が見切れたものがs2
である場合、s2
はs1
の前頭部分または末尾部分とだいたい一致するはずです。よって、levenshtein(s1[:len(s2)], s2)
とlevenshtein(s1[-len(s2):], s2)
を比較して、どちらかの編集距離が閾値より小さければ、s2
はs1
の一部分であり重複していると判定できます。
これを使えば「ツイートが見切れていて全体の一部分しか読み取れない」という問題は解決できそうです。さらに、もしs1
とs2
が完全一致していたら編集距離は0になりますし、見切れておらず表記ブレのみだったとしても、この2つの比較を行うことで編集距離はある程度小さいスコアになるはずです。つまり、この一連の処理で重複除去に関するほとんどのケースをカバーできることになります。
実装は以下の通りです。draft_list
はこれまでに読み取った文章のリスト、new_str
は今回新しく読み取った文字列です。
def append_string(draft_list: List[str], new_str: str,
min_length=10, search_num=10):
# new_strがあまりに短すぎる場合は追加しない
if len(new_str) < min_length:
return draft_list
# 一致判定の精度を上げるため空白は除く
new_str = new_str.replace(" ", "")
# 短い方(s2)が長い方(s1)のprefixまたはsuffixに一致するかを判定する
def check_duplicate(s1, s2):
return (levenshtein(s1[:len(s2)], s2) <= 0.5 * len(s2) or
levenshtein(s1[-len(s2):], s2) <= 0.5 * len(s2))
str_to_remove = []
# draft_listの末尾search_num個を調査
for s in draft_list[-search_num:]:
# もしnew_strと一致してより長い文章が既にdraft_listにある場合、
# new_strは追加するのを止める
if len(s) >= len(new_str) and check_duplicate(s, new_str):
return draft_list
# もしnew_strと一致してより短い文章が既にdraft_listにある場合、
# new_strを採用してdraft_listにある方を削除する
elif len(s) < len(new_str) and check_duplicate(new_str, s):
str_to_remove.append(s)
for s in str_to_remove:
draft_list.remove(s)
draft_list.append(new_str)
return draft_list
全体の流れ
以上を踏まえてmain関数は以下のようになります。
def main():
options = parse_args()
# 動画を読み込んで保存する
img_folder = './cropped_images'
split_video(options.video_path, img_folder,
options.frame_interval_sec)
# 読み込む画像をリストアップする
img_list = sorted([os.path.join(img_folder, fn)
for fn in os.listdir(img_folder)])
# draft_listに認識した文字列を追加していく
draft_list = []
for i, img in enumerate(img_list):
print(f"reading image {i+1}/{len(img_list)}")
if options.ocr_mode == 'tesseract':
draft_list = read_strings_tesseract(
img, draft_list, options.margin_ratio)
elif options.ocr_mode == 'google_api':
draft_list = read_strings_google_api(
img, draft_list, options.margin_ratio)
# 認識した結果をjsonとして書き出す
draft_json = {i: e for i, e in enumerate(draft_list)}
with open(options.output_file, 'w') as f:
json.dump(draft_json, f, indent=4, ensure_ascii=False)
実際に下書きを読み取ってみる
さて、実装が終わったので自分の下書きツイートを読み取ってみます。まずiPhoneの画面録画モードで、下書き画面を上から下までスクロールして撮影します。私の場合ゆっくりめにスクロールすると50秒くらいの動画になりました。画像は動画から0.2秒単位で切り出すことにし、全体で272枚の画像を得ました。
撮影した動画をrootディレクトリに置いて、以下を実行すると下書きテキストが収められたdraft.json
が生成されます。
// tesseractを使う場合
python main.py --video-path video.mp4 --frame-interval-sec 0.2 --margin-ratio 1.5 --ocr-mode tesseract
// Google Cloud Vision APIを使う場合
python main.py --video-path video.mp4 --frame-interval-sec 0.2 --margin-ratio 1.5 --ocr-mode google_api
tesseract
まずtesseractを使った場合、実行時間は約625秒でした。けっこう時間かかります...。
読み取られた総ツイート数は832でした。以下は結果の抜粋です(この記事の冒頭にある画像の周辺です)。
{
...
"27": "「人類みんなえらい!」じゃなくて「あなたが一番えらい!」って言ってほしいんですよ、本当はね",
"28": "ご4しンプレーロリ欠SV)!や",
"29": "「洗濯物と楽天ポイントはほぼほっといても貯まる、ってとことわざでもいいますからね」",
"30": "東京で「この駅周辺なら休日でもどこかしらのカフェチェーンで座れて電源もほぼ確実に確保できます」って駅ありますか?",
"31": "みんななんだかんだ言って収入が増えたら嬉しいし拍手喝来を浴びたら嬉しいし他者から好かれたら嬉しいだろ。もっともらしい理由をつけて目を逸らしている炉で。",
"32": "例えば「あなたが今までで一番驚いたことはなんですか」と聞かれたら僕は「人生で一番ですか?えーっと、なんだるろうな一」って考えちゃうけど、「ちょうど先週の話なんですけど.…」ってとりあえず何か話し始められる人の方が\"正解\"なんだよな",
"33": "逆に「CDよりライブの方が上手い」ってコメント書かれないいアーティストっているんか?",
"34": "放送大学に入ると論文閲覧とかオンライン学習教材とかが無料になって嬉しいという話がよく流れてくるけど、僕としてはyoutubepremium無料とか漫画読み放題とかのほうがもっと嬉しいな!!",
"35": "指宿二コルサコフ",
"36": "ロマンスの神様とか粉寺とか、サビに入ったところの高音を当てられるかという技術的な部分が焦点になることが多くて、結局何について歌ってる歌なのかもうほとんど誰も気にしてない気がする",
"37": "次はユニバにリングフィットアドベンチャーランドでに才紀た3の",
...
}
Google API
次に、Google Cloud Vision APIを使った場合、実行時間は約240秒でした。こちらのほうが速いです。
読み取られた総ツイート数は770でした。以下は結果の抜粋です。
{
...
"27": "「人類みんなえらい!」じゃなくて「あなたが一番えらい!」って言ってほしいんですよ、本当はね",
"28": "「洗濯物と楽天ポイントはほっといても貯まる、ってことわざでもいいますからね」",
"29": "東京で「この駅周辺なら休日でもどこかしらのカフェチェーンで座れて電源もほぼ確実に確保できます」って駅ありますか?",
"30": "みんななんだかんだ言って収入が増えたら嬉しいし拍手喝采を浴びたら嬉しいし他者から好かれたら嬉しいだろ。もっともらしい理由をつけて目を逸らしているだけで。",
"31": "次はユニバにリングフィットアドベンチャーランドできてほしい",
"32": "逆に「CDよりライブの方が上手い」ってコメント書かれないアーティストっているんか?",
"33": "例えば「あなたが今までで一番驚いたことはなんですか」と聞かれたら僕は「人生で一番ですか?えーっと、なんだろうなー」って考えちゃうけど、「ちょうど先週の話なんですけど…..」ってとりあえず何か話し始められる人の方が\"正解\"なんだよな",
"34": "放送大学に入ると論文閲覧とかオンライン学習教材とかが無料になって嬉しいという話がよく流れてくるけど、僕としてはyoutubepremium無料とか漫画読み放題とかのほうがもっと嬉しいな!!",
"35": "ロマンスの神様とか粉雪とか、サビに入ったところの高音を当てられるかという技術的な部分が焦点になることが多くて、結局何について歌ってる歌なのかもうほとんど誰も気にしてない気がする",
"36": "A「取れたボタン縫い付けたいんだけど地上の星持ってない?」B「は?」A「間違えた、糸持ってない?」B「糸を中島みゆきの曲名として認識してるの珍しいな」",
...
}
この部分だけを見ても、後者の方が基本的な文字認識の精度が高い印象があります。
また、tesseractの方は見切れている文字やツイート文ではない部分を無理やり文字認識した結果、意味をなさない文字列が追加されていることがありました。そのせいでGoogle APIの場合よりも60個ほど総ツイート数が多くなっていると思われます。
ただ、両者とも、ツイート単位で文字列を取り出すことには成功しており、ツイートの重複もそこまで見られないため、今回の実装で実現したかったことはある程度成功していると言えます。
Google APIの方は精度と速度ともに総じてかなり良い結果ですが、改善点を挙げるとすれば、
- 特に画面の端の領域で文字の読み取り間違いがしばしば起こる。
- 画像などはさすがに保存できない。
- ツイート内に改行があると別のツイートとして認識されてしまいやすい。
- 重複処理のせいでツイートの順番は保証されない。
などがありそうです。
まとめ
力技でTwitterの下書きを取り出す方法を実装しました。結果を見るとまだ完璧とは言えませんが、データを取り出してバックアップするという当初の目標はある程度達成されたと思っています。
ただ、私はやりたくてこんなことをしているわけではありません。もしもっと簡単に下書きデータを取得する方法をご存知の方がいたらぜひ教えてください。
そして、そもそも下書きを溜めすぎるのは決して褒められた行為ではありません。どうか良い子の皆さんは真似しないでください。