BigQueryにエクスポートしたGA4のデータと比較する

BigQueryにエクスポートしたGA4のデータの概要を把握する で、ある程度作りを把握したので、この記事ではBigQueryでクエリを書いて集計した結果とGA4のコンソールで表示した結果を見比べながら理解を深めていく。

イベントについて

GA4ではユーザーの行動(ページビュー、セッション開始、クリック等)は「イベント」として計測されている。
そこでまずはイベントの値として何が入っているかを見比べてみる。

[BigQuery側]
イベントはevent_name というカラムとして格納されている。

SELECT distinct(event_name)
FROM analytics_<property_id>.events_YYYYMMDD

イベントのイメージとしては、例えばユーザーがサイトにアクセスしたら session_start イベントと page_view イベントが発生し、さらに初回の訪問ならfirst_visit イベントも発生する。

[コンソール側]
管理 > プロパティ設定 > データの表示 > イベント より、設定しているイベントを確認できる。

BigQuery側とコンソール側でイベントが一致していることが確認できた。

なお、GA4のイベントには4種類あり、上記の7つのイベントは記載の区分に当てはまる。

  • 自動収集イベント:自動で計測される 今回だと first_visit, session_start
  • 拡張計測機能イベント:GA4管理画面上で有効にすると自動で計測される 今回だと page_view, scroll, click
  • 推奨イベント:ユーザーが設定するが、名前とパラメータがGoogleで定義されている
  • カスタムイベント:ユーザーが自由にイベント名を定義する 今回だと internal_link_click, top_page_view

[GA4] About events – Analytics Help

イベントの概観を確認できたので、以下でイベントを指定して値を集計してみる。

first_visit イベント

チャネルごとの新規ユーザー数
first_visitは初回の訪問時に発生するイベントなので、ある日に初めて訪問したユーザー数をチャネル別で集計してみる。

[BigQuery側]

SELECT
  traffic_source.name AS medium,
  COUNT(DISTINCT user_pseudo_id) AS new_users
FROM
  analytics_<property_id>.events_YYYYMMDD
WHERE
  event_name = "first_visit"
GROUP BY
  medium

※ ユーザー数について
COUNT(DISTINCT user_pseudo_id) でユーザー数を集計している。
以下確認より、user_pseudo_id はアクセスのあったユーザーのブラウザに割り当てられる一時的なID(同じユーザーでもブラウザが異なれば異なるIDとなる)と理解している。今回、user_pseudo_id はユーザーエクスプローラで「有効なユーザー ID」として表示される値と一致していた。

コンソール上で以下の説明があり、このサイトでは「ユーザー ID」の設定がなくウェブサイトのため、今回は実質「クライアント ID」であると解釈した。

有効なユーザー ID とは、ユーザーのブラウザ インスタンスまたはアプリのインストールに関連付けられている一意の ID です。Google アナリティクスではこの ID を使って、同一ユーザーのセッションによる訪問が識別されます。

有効なユーザー ID には、利用可能であればユーザー ID が使用されます。利用可能でない場合は、デバイス ID が使用されます。デバイス ID は、モバイルアプリのアプリ インスタンス ID またはウェブサイトのクライアント ID です。

[コンソール側]
ライフサイクル > 集客 > ユーザー獲得 より、ディメンションを「最初のユーザーのデフォルトチャネルグループ」に設定して確認

BigQuery側とコンソール側で、ユーザー数が一致していることが確認できた。

page_viewイベント

ページごとの表示回数とユーザー数
page_viewはサイト内のページにアクセスすると発生するイベントなので、ある日のページごとの表示回数とユーザー数を集計してみる。

[BigQuery側]

SELECT
  event_params.value.string_value AS page_path,
  COUNT(*) AS pageviews,
  COUNT(DISTINCT user_pseudo_id) AS users,
FROM
  analytics_<property_id>.events_YYYYMMDD
  ,UNNEST(event_params) AS event_params
WHERE
  event_name = 'page_view'
  AND event_params.key = 'page_location'
GROUP BY
  page_path

※ テーブルのつくりの確認
クエリだけだと理解しづらいので、使用しているカラムをプレビュー画面に記載した

※ ユーザー数については上記と同様

[コンソール側]
ライフサイクル > エンゲージメント > ページとスクリーン より確認

BigQuery側とコンソール側で数が一致していることが確認できた。

clickイベント

ある日に、どのページで何回クリックが発生したかをカウントしてみる。

[BigQuery側]

SELECT
  event_name,
  event_params.value.string_value as page_location,
  COUNT(*) AS click_count
FROM
  analytics_<property_id>.events_YYYYMMDD
  ,UNNEST(event_params) AS event_params
WHERE
  event_name = 'click'
  and event_params.key = "page_location"
GROUP BY
  event_name,
  page_location

[コンソール側]
探索 からレポートを作成し確認する。
ディメンション(行)としてページロケーション、指標(値)としてイベント数を指定して、フィルタでイベント名をclickにする。

BigQuery側とコンソール側で数が一致していることが確認できた。

まとめ

この記事では、BigQueryにエクスポートしたデータに対する集計結果とGA4のコンソールでの集計をイベントを切り口として比較した。今回の範囲ではBigQueryの集計とコンソールの集計が一致したが、試している中では一部微妙にずれる集計も存在したため、次にその要因と対応方法を確認したい。

不定期番組の放送を通知する仕組みをChatGPTと作る

これは GMOペパボディレクター Advent Calendar 2023 15日の記事です。

HNK 番組表API を利用して、登録したキーワードに合致する番組をチェックし、Slackに通知する仕組みを作ってみた。

Slack通知の例

この記事では 1. 作成物の概要 と 2. 今回ChatGPTの力を借りて作成を行ったのでその感想 を記載する。

作成のきっかけ

普段、テレビは録画しておいた番組をみる機会が多く、週に1回程度番組表をざっと眺めて録画の予約をしている。この方法だと、関心のある番組なのに録り忘れることがしばしばあった(特に数ヶ月に1回など不定期放送の番組は見逃しがちだった)。

条件に合致する番組があったら通知してくれるアプリとかありそうだな、と思って探したが、放送直前の通知が多く、数日手前の段階での通知機能を持つアプリは見つけることができなかった。

この調査の中で、HNK 番組表API の存在を知り、今回の作成を行った。※1

仕組みの概要

  • HNK 番組表API を通じて1週間分の番組情報を取得する
  • あらかじめ設定しておいた番組名かキーワードに合致する番組をピックアップする
  • ピックアップした番組の情報をSlackに通知する

コードはこちら:https://github.com/yrarchi/nhk_programs_checker

ChatGPTとの共同作業

今回の作成はChatGPTに助けてもらいながら行ってみた。条件を与えて最初のベースになるコードを書いてもらい、条件を付加してコードを修正してもらってみたり、リファクタリングをしてもらってみたりした。

意図通り示してもらえる部分も、意図を汲んでもらえない場合もあったが、自分で一から作るのに比べ3分の1程度の時間で完成したように思う。

以下に質問の具体例をいくつか示す。

  • 最初のベースになるコードを書いてもらう
(前略 意図した仕組みと作り方を伝え、懸念点等を聞いていた) 
NHK APIキーの取得はできたので、大きく以下のブロックに分けて進めたいと考えています。 
1. NHK APIキーをたたいて、番組名一覧を取得し、登録した番組名と一致する番組名があるかをチェックするPythonスクリプトを作成する 
2. Slackに通知する仕組みを追加する
まずは1.について、コードを示してください。なお、1つのスクリプトにまとめてしまわず複数のファイルに適切に分割してくださるとありがたいです。 

→ requestsライブラリを使用してHTTPリクエストを送信し、返された番組情報を処理する20行程度のコードを書いてくれた。それを修正していけば良いので、動き出しのはずみになった

  • 条件を付加してコードを修正してもらう
現在のコードは以下です。 
このコードだと、同じ番組が再放送等で複数存在する場合に、それぞれtitleやcontentを出力してしまいます。 同じ番組の場合はそれらの出力は1回になるよう、コードを修正してください。

→ 意図を汲んでコードを修正してくれたものの、意図通りには処理されないものになっていなかったため、その要因と思われる点を伝え、再修正してもらった

以下のような、Pythonの辞書があります。 
dict = { 
'title_a': {'subtitle': 'subtitle', 'content': 'content', 'start_time': '2023-11-28T04:10:00+09:00'}, 
'title_b': {'subtitle': 'subtitle', 'content': 'content', 'start_time': '2023-11-29T04:10:00+09:00'} 
} 
これを以下のjsonに当てはめて、dictの長さ分だけ生成するコードを書いてください。
 なお、jsonは外部ファイルから読み込み、また当てはめた結果をjsonとして出力するようなコードとしてください。

→ これはおそらくこちらの伝え方がうまくなく、一応コードを生成してくれたものの再度

示していただいたコードはjinja templateを使ったら簡潔に書けるのではないでしょうか 

と方法をこちらから指定して再度書き直してもらった

  • リファクタリングをしてもらう
以下のコードについて、可読性を上げられることはないか・変数名としてより適切なものはあるか・セキュリティの観点から修正すべき点がないか指摘してください。 

→具体的なコードの箇所を示しての指示を期待したが、一般的な注意点の提示だった

今後の発展

以下のような改善を今後してみたい。

  • 今回はコードの自動実行の設定までいかなかったので、週に1回定期実行されるようにしたい
  • 今回は指定したキーワードに合致した番組を通知する形だが、「この番組に興味を持つならこれも好きかも」と推薦を行えるようにしてみたい

※1 今回のAPIはNHKの番組のみが対象だが、不定期放送で見逃しがちな番組の多くがNHKだったため、用途上大きな問題がなかった

BigQueryにエクスポートしたGA4のデータの概要を把握する

GA4のデータを異なるGoogleアカウントからBigQueryにエクスポートする」の操作により、BigQueryにGA4のデータがエクスポートされるようになった。

この記事では、エクスポートされたデータの作りやGAのコンソール上での見え方との比較を行って、概要の把握を進める。

BigQueryでのテーブル構成の概要

BigQueryへのエクスポートには、数分前のデータが都度入ってくるストリーミングエクスポートと、1日1回エクスポートされる2種類があり、今回は後者のみとしている。

この場合、以下の形式でデータが作成される。
・データセット:analytics_<property_id>
・テーブル:日毎のテーブル events_YYYYMMDD

テーブルの中身をpreviewで確認すると、event単位でネストされていることがわかる。例えば下記画像の例だと、session_startとpage_viewのイベントそれぞれに対して、page_title等のパラメータとその値が格納されている。

ユーザーエクスプローラーの結果と見比べて概要を把握する

GA4のコンソール上での表示と、BigQueryのデータを見比べて同じであることを確認してみる。user_pseudo_id を指定して比較する。

  • BigQuery側
SELECT
  event_date,
  event_timestamp,
  DATETIME(TIMESTAMP_MICROS(event_timestamp), "Asia/Tokyo") as event_datetime,
  event_name
FROM 
  analytics_<property_id>.events_20231010
WHERE 
  user_pseudo_id = <user_pseudo_id>
  • GAコンソール側
    [探索] → [テンプレート ギャラリー] → [ユーザー エクスプローラ] より、user_pseudo_idを指定して表示する。

「イベント4件」となっていて、user_engagementはリストに表示されていないものの、右上の「上位のイベント」として表示はされているので、BigQuery側と項目としては同じであることが確認できる。また、日時も合致していることが確認できる。

複数日にまたがってクエリを書くには

events_YYYYMMDD という名称で日毎にテーブルが作成されるため、複数日にわたって集計するクエリを書きたい場合は、複数のテーブルを結合する必要がある。

BigQueryのワイルドカード テーブルを使用すると、テーブル名をワイルドカードで指定してクエリを書くことができる。
 Query multiple tables using a wildcard table | BigQuery | Google Cloud

例えば、2023/10/01〜2023/10/20の20日分を集計したい場合は以下のように書ける。

SELECT 
	…
FROM
  analytics_<property_id>.events_*`
WHERE
  _TABLE_SUFFIX BETWEEN "20231001" AND "20231020"

大まかだが、以上でBigQueryへエクスポートされたデータの作りを確認したので、次に具体的にクエリを書きながら理解を深めていく。
→ BigQueryにエクスポートしたGA4のデータをコンソールと比較する

GA4のデータを異なるGoogleアカウントからBigQueryにエクスポートする

UAからGA4になったことで、無料のGoogle AnalyticsでもBigQueryにエクスポートできるようになった※ ので、試しにやってみた。今回、Google CloudとGoogle Analytics のGoogleアカウントが異なることで何点かつまづいた箇所があったので、それを踏まえて行った手順を記載する。

※ BigQueryサンドボックスを利用すると、その制限内ならBigQueryの利用も無料となる 参照:[GA4] BigQuery Export- Analytics Help

具体的な手順

基本的には下記のヘルプの手順に沿って進めていく。

[GA4] Set up BigQuery Export – Analytics Help

  1. BigQuery APIを有効にする
    [API とサービス]>[ライブラリ] より有効にする
BigQuery API
  1. プロジェクトを作成する
  2. GA4プロパティをBigQueryにリンクさせる
    GAの管理画面 [管理] > [サービス間のリンク設定] > [BigQuery のリンク] から「リンク」をクリックする
GA4のBigQueryのリンク設定画面

ヘルプの記載だと、「[BigQuery プロジェクトを選択] をクリックして、アクセス可能なプロジェクトのリストを表示します」とあるが、選択できるプロジェクトが表示されなかった。

これは、GAとGoogle Cloudで異なるGoogleアカウントを利用していることが要因だったようで、Google CloudでGAのGoogleアカウントをIAMから編集者として追加したら、プロジェクトが表示されるようになった。追加は[IAMと管理] > [IAM] より、「アクセス権を付与」をクリックして行う。

Google Cloud のIAMでGA4のGoogleアカウントを追加

GAの管理画面に戻り、BigQueryのリンクを再び行ってみる。

上記アカウントの追加によりプロジェクトは表示されるようになったが、権限が足りないと表示される。

GA4の管理画面でエラーが表示される

選択した Google Cloud プロジェクトへのアクセス権がないため、リンク処理を完了できません。
必要な権限: serviceusage.services.enable、resourcemanager.projects.setIamPolicy。

挙げられている2つの権限 serviceusage.services.enable も serviceusage.services.getも、今回付与した編集権限の中に含まれていた。おかしいな…と思ったら、ヘルプの中に以下の記載があり、エラーメッセージには記載のない resourcemanager.projects.setIamPolicy が編集権限には含まれていなかった。

BigQuery リンクの作成に最低限必要な権限は次のとおりです。
・resourcemanager.projects.get プロジェクトを取得する
・resourcemanager.projects.getIamPolicy 権限のリストを取得する
・resourcemanager.projects.setIamPolicy ユーザーがこのプロジェクトでリンクを作成する権限を持っているかどうかを確認する
・serviceusage.services.enable BigQuery API を有効にする serviceusage.services.get BigQuery API が有効かどうかを確認する

そこで、必要な権限のみ付与したカスタムロールを作成した。

GA4のGoogleアカウントの権限を編集権限からこのカスタムロールに変更したらエラーが出なくなり、進めるようになった。

三度GAの管理画面に戻り、BigQueryのリンクを行う。エクスポートは毎日を選択し、データ数が少ないので除外イベントは設定しなかった。

何かエラーが起きた。

時間をおいて再度実行しても解決せず、同じエラーが出た。
ヘルプに以下の記載もあり、

アナリティクスへのログイン時には、BigQuery プロジェクトの所有者権限(アクセス要件の詳細については、以下の権限を参照)と、リンクするデータ ストリームを含むアナリティクス プロパティの編集者ロールの両方があるメールアドレスを使用します

「アクセス要件の詳細については、以下の権限を参照」に記載の権限は付与しているのだが、結局所有者権限が必要なのだろうか…となり、権限を付与してみたらエラーが起きず、通るようになった。

これで、一応BigQueryにGA4のデータがエクスポートされるようになったので、次にエクスポートされたデータの概要を確認する。
BigQueryにエクスポートしたGA4のデータの概要を把握する

Healthデータのスプレッドシートへの送り込み

最近の活動量計等は歩数や体重などのデータをスマホに送信できることも多い。それが可能な活動量計と体脂肪計を利用しているが、アプリで時折変化を眺めて満足してしまうことが多かった。予め用意されたフォーマットで見るだけでなく自分で分析したいし、データをバックアップしておきたいので、スプレッドシートにデータを定期的にアップロードするようにした。

できること

毎日定時にスプレッドシートに下記のような1行分のレコード(「昨日」の各種値)が追加される

仕組み

スマホ上でデータはHealthアプリに集約する(ここは各デバイスの純正アプリにすでにHealthへの同期機能があったため、それを利用)。HealthアプリからShortcutsアプリでデータを取り出し、GASを介してスプレッドシートに書き出す。

具体的な手順

  1. Shortcutsアプリで必要なデータを取り出してPOSTリクエストを送るショートカットを作成する
    • 色々はまるポイントがあったので、以下画像内で記載する
  1. GASでPOSTリクエストを受けてシートに書き込むスクリプトを作る
function doPost(e) {  // POSTリクエストを送信されたら実行される
  var data = getData(e);
  appendData(data);
}

function getData(e) {
  var params = JSON.parse(e.postData.getDataAsString());   // POSTされたデータを取得
  var values = JSON.parse(params.results);  // Shortcutsアプリで作成したショートカットで、results: {values: {Weight: …} の形式でデータを入れている
  return [
    new Date(), 
    values.Weight, 
    values.BodyFatPercentage, 
    values.RestingEnergy,
    values.ActiveEnergy,
    values.Steps,
    values.BodyTemperature,
  ];
}

function appendData(data) {
  var sheetName = "HealthCareApp";
  var spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
  var sheet = spreadsheet.getSheetByName(sheetName);
  sheet.appendRow(data);
}
  1. 1.で作成したショートカットを毎日定時に実行するよう設定する
    Shortcutsアプリの中でAutomationから定時実行を設定できる

感想

  • ショートカットの作成をスマホ上で行うのがつらい。
    • 簡単なショートカットだと問題ないのだけど、今回のように長くなるとぽちぽち押しての操作がつらくなる。Shortcutsアプリ自体はMacOSにもあるのでPCでもショートカットの編集は行えるが、HealthアプリがMacOSにはないのでHealthアプリの操作をする部分はPCでは編集できなかった。
    • 編集後の差分を確認できないので、コードとして管理したい気持ちになった(Shortcutsアプリの意義と逆方向の感想になってしまうが…)

グラフを動画化する

これは GMOペパボディレクター Advent Calendar 2022 17日の記事です。


上記のような感じで、時系列かつ複数の要素が同時に起こるデータを動画化してみたので、その作成過程を記載する。
(自分の手元では確認したのだけど、動画ちゃんと動いているかな…)

例えば、どのような検索キーワードが合わせて検索されることが増えているか、社内でどの部署同士の結びつきが強くなっているか、などの可視化に使えそうなイメージをしている。

ここではBigQueryの公開データセット theLook eCommerce を例として使用させていただいた。theLook eCommerce は架空の衣料品サイトにおける商品や注文等のデータが格納されているので、ある商品分野(「シャツ」「ジーンズ」など)の商品の併せ買いのされやすさの変化を可視化してみる。

データの前処理

使用したのは theLook eCommerce の productsテーブルとorder_items テーブルの2つ。

年月単位で、同時注文したカテゴリの組み合わせ別に注文数を集計する。

WITH duplicate_order_combinations AS(
  SELECT
    order_items.order_id,
    DATE(TIMESTAMP_TRUNC(order_items.created_at, MONTH)) AS created_at,
    CASE 
      WHEN products_destination.category IS NULL 
        THEN products.category
      WHEN products.category < products_destination.category 
        THEN CONCAT(products.category, "-", products_destination.category) 
      ELSE CONCAT(products_destination.category, "-", products.category) 
    END AS categories,
  FROM 
    bigquery-public-data.thelook_ecommerce.order_items
  INNER JOIN 
    bigquery-public-data.thelook_ecommerce.products
    ON order_items.product_id = products.id
  LEFT JOIN 
    bigquery-public-data.thelook_ecommerce.order_items AS order_destination
    ON order_items.order_id = order_destination.order_id
    AND order_items.product_id != order_destination.product_id
  LEFT JOIN 
    bigquery-public-data.thelook_ecommerce.products AS products_destination
    ON order_destination.product_id = products_destination.id
),

order_combinations AS(
  SELECT
    order_id,
    created_at,
    categories,
  FROM
    duplicate_order_combinations
  GROUP BY
      order_id,
      created_at,
      categories
)

SELECT
  created_at,
  SPLIT(categories, "-")[offset(0)] AS category, 
  CASE WHEN categories LIKE "%-%" 
    THEN SPLIT(categories, "-")[offset(1)] ELSE NULL END AS other_category, 
  COUNT(*) AS _count
FROM 
  order_combinations
GROUP BY
  created_at,
  categories

以下のような感じで集計される。以下のレコードだとAccessoriesとOuterwear & Coatsの同時購入が2020-05に20件あったということになる。

created_at, category, other_category, _count
2022-05-01, Accessories, Outerwear & Coats, 20

この結果にdataという名称をつけておく。

また、カテゴリの一覧も取得してcategoriesという名称をつけておく。

SELECT distinct(category)
FROM bigquery-public-data.thelook_ecommerce.products

グラフ化

グラフを扱えるNetworkX と可視化を行う Matplotlib を使用してグラフ化している。

以下の環境で実行を確認した。

Python 3.8.16
networkX 2.8.8
pandas 1.3.5
matplotlib 3.2.2

大まかな流れとして

1. ノード(今回だと購入した商品のカテゴリ)とエッジ(今回だと同時に購入した商品のカテゴリをつなぐ)を作成する

2. ノードとエッジを描画する(多い組み合わせほど太いエッジとなるようにし、同じカテゴリで複数購入された場合は同じノードを環状につなぐようにした)

import networkx as nx
import pandas as pd
from matplotlib import pyplot as plt

nlist = [
  ['Pants', 'Shorts', 'Skirts', 'Pants & Capris', 'Jeans'],
  ['Tops & Tees', 'Sweaters', 'Fashion Hoodies & Sweatshirts', 'Blazers & Jackets', 'Suits & Sport Coats', 'Outerwear & Coats'],
  ['Jumpsuits & Rompers', 'Dresses', 'Suits', 'Clothing Sets', 'Active', 'Swim', 'Maternity', 'Sleep & Lounge'],
  ['Socks', 'Socks & Hosiery', 'Leggings', 'Intimates', 'Underwear', 'Plus', 'Accessories']
]  # 使用しているshell_layoutの引数 nlistで同じリストに属するカテゴリが同じ円状に配置される


def make_graph(data, term, categories, origin_column, destination_column):
  data_internal = data[data[destination_column].isnull()]
  data_external = data[~data[destination_column].isnull()]
  G = nx.Graph()

  # nodeの作成
  for category in categories:
    counts = data_internal.query(f'{origin_column} == @category')['_count'].values
    if len(counts) > 0:
      G.add_nodes_from([(category, {'count': counts[0]})])
    else:
      G.add_nodes_from([(category, {'count': 1})])  # 単品での購入が行われていない場合

  # edgeの作成
  for origin, destination in zip(data_external[origin_column], data_external[destination_column]):
    weight = data_external.query(f'{origin_column} == @origin and {destination_column} == @destination')['_count'].values
    if len(weight) > 0:
      G.add_edge(origin, destination, weight=weight[0])
    else:
      G.add_edge(origin, destination, weight=0)
  
  return G


def plot_graph(G, term, nlist):
  # graphの描画
  plt.figure(figsize=(15, 15))
  pos = nx.shell_layout(G, nlist=nlist)

  node_size = [d['count']*8 for (n, d) in G.nodes(data=True)]
  nx.draw_networkx_nodes(G, pos, node_color='w', edgecolors='b', alpha=0.6, node_size=node_size)
  nx.draw_networkx_labels(G, pos)
  edge_width = [d['weight']*0.08 for (u, v, d) in G.edges(data=True)]
  edge_color = edge_width / max(edge_width)
  nx.draw_networkx_edges(G, pos, alpha=0.6, edge_color=edge_color, width=edge_width, edge_cmap=plt.cm.cool)

  plt.axis('off')
  title = f'{pd.to_datetime(term).year}-{pd.to_datetime(term).month}'
  plt.title(title, loc='left', fontsize=24)
  plt.savefig(f'{title}.png')


for month in data['created_at'].unique():
  data_month = data.query('created_at==@month')
  G = make_graph(data_month, month, categories['category'], 'category', 'other_category')
  plot_graph(G, month, nlist)

以下のような感じで月毎に画像が生成される。

動画化

動画への変換は OpenCV を利用した。上記で月毎に画像を生成した際、年月をタイトルとして保存してあるので、その順で重ねて動画にしている。

import cv2


def movie_export(prefix):
  fourcc = cv2.VideoWriter_fourcc('m', 'p', '4', 'v')
  video  = cv2.VideoWriter(f'{prefix}_movie.mp4', fourcc, 6.0, (1080, 1080))

  for month in sorted(data['created_at'].unique()):
    title = f'{pd.to_datetime(month).year}-{pd.to_datetime(month).month}'
    img = cv2.imread(f'/content/{title}.png')
    video.write(img)
  video.release()


movie_export('category')

次のような動画が生成される。

類似度を用いてレシートOCRの精度を上げる

以前作成したレシート画像から品目や価格を読み取りCSV化する仕組みについて、品目の判定精度が低く困っていた。そこで、読み取った品目と過去履歴の品目との類似度を求める仕組みを組み込むことで、品目判定の精度が上がったのでやったことを記録する。

困りごと

元々の仕組みでは、下記のようにOCRによる品目の読み取り結果とそれを人が見て修正した結果をペアで履歴としてたまるようにしておき、全く同じ読み取り結果を得た時にのみ人が修正した結果に自動で変換するようにしていた。
# 読み取り結果,人が修正した結果
キャベッ,キャベツ
TV1.0テイシボ,牛乳
Pロコリー,ブロッコリー  # 次回以降に「Pロコリー」という読み取り結果が得られた時に「ブロッコリー」に自動変換する
普段買うものはある程度決まっているため、履歴がたまってくれば人手による修正は減っていくだろうと思っていた。実際は読み取り結果が予想よりバラエティに富んでいて(読み取り精度が思ったより低くて)、人手による修正はなかなか減らなかった。
例えば、以下はレシートに「ブルガリアYG脂肪0プ」と印字されていた場合の読み取り結果の履歴一覧である(印字は「ブルガリアヨーグルト脂肪0プレーン」の略称と思われる)。間違い探しのように毎回少しずつ異なる結果が得られて、なかなか自動修正されなかった。
# 読み取り結果,人が修正した結果
ブルガリ~YG脂肪0プ,ヨーグルト
プルガリアYG脂肪0Qプ,ヨーグルト
フルガリアYG脂肪0プ,ヨーグルト
ブルガリアYG脂肪0プ,ヨーグルト
ブルガリアYG脂肪0プ。,ヨーグルト
ブルガリア了YG脂肪0Uプ,ヨーグルト
フルガリア了YG脂肪0プ,ヨーグルト
ブルガリア了YG脂肪0プ,ヨーグルト
ワルガリアYG脂肪0プ,ヨーグルト
上記の例だと漢字の「脂肪」は毎回正しく読み取れている。ただ、漢字でも正しく認識されていないことも多い。 例えば「超熟(6)」と印字されていた場合の読み取り結果の履歴一覧は以下のようになっている。熱と熟など人間から見てほぼ同じに見えるものから、弟と熟などそこまで似ていないように見えるものまである。
# 読み取り結果,人が修正した結果
記熟(6),食パン
超衣(6),食パン
超熟(6),食パン
超熱(6),食パン
超誰(6),食パン
超弟(6),食パン
超認(6),食パン
履歴を眺めているとなんとなく画数の多い漢字の方が正答率が高く、カタカナやひらがなは正答率が低い傾向に見えた(なお、ここでは示していないが半角カタカナの印字の場合、元の文字列を類推することが不可能なレベルのもっと壊滅的な結果が得られる)。
今回の対応範囲は、上で示したヨーグルトと食パンのような人間が見れば同一品目と類推できるようなレベルの読み取りであれば、自動で変換して欲しいという点においた。

やったこと

読み取り結果に完全一致する品目が履歴になくても、類似度が高い品目が履歴にあれば自動でその品目名に修正するようにした。
具体的には、履歴の品目の文字列群と今回読み取った文字列のレーベンシュタイン距離を計算し、距離が最短の品目について閾値を下回っていればその品目(の人が修正した結果)に変換するようにした。距離の閾値については、いくつか試した上でバランスを見て定めた(後述)。
※ 類似度として使用したレーベンシュタイン距離については別記事に整理した
イメージを持ちやすくするためまずはレーベンシュタイン距離を求めた結果を示す。
※ 以下、レーベンシュタイン距離は文字列の長さで正規化しているため、最大で1となる
「ブルガリアYG脂肪0プ」という文字列に対してだと、以下のようなレーベンシュタイン距離となった。ブルガリアヨーグルト関連の文字列に対して距離が小さい(=類似度が高い)一方、他の品目に対しては距離がぐっと伸びている(=類似度が低い)ことがわかる。
# 読み取り結果, 人が修正した結果, レーベンシュタイン距離
ブルガリア了YG脂肪0プ, ヨーグルト, 0.08
プルガリアYG脂肪0Qプ, ヨーグルト, 0.17
ニニガリアツアグ, 厚揚げ, 0.73
アルカリ乾電池単1形, 電池, 0.82
リがクリループ\\", ガム, 0.91
今回の目的に対し使えそうな感触を得られたので、以下のデータを用いて自動変換する距離の閾値を求めていった。
- 履歴にある品目数: 321件(人による修正結果単位で見ると141件)
- テストデータの品目数: 56件
いくつか具体例を示す。
例1:読み取り結果が「シーチキン[|Newマイルド」だった場合、履歴の品目のうち最小距離となるのは「シーチキンNeeマイルド」だった。これらは人が同一品目と判断する例なので、一致すると判断し自動変換して欲しい。両者の距離は0.21だったため、閾値を0.21以上に設定していれば正しく変換できることになる。
例2:読み取り結果が「ミツカンやさしいお酢3」だった場合、履歴の品目のうち最小距離となるのは「生しいたけ」だった(両者の文字列に「しい」が共通しているため最小となったと思われる)。これらは人が別品目と判断する例なので、読み取り結果に一致する品目が履歴にないと判断して欲しい。両者の距離は0.82だったため、閾値を0.82以上に設定した場合は誤変換してしまうことになる。
このように、閾値をいくつに設定するかによって正しく変換できなかったり、誤って別の品目に変換してしまう可能性がある。
閾値を0.3から0.8の範囲で変えてみて、変換結果の割合を確認したのが下図となる。
完全一致した場合のみ変換する既存の方法に比べ、レーベンシュタイン距離を用いると変換成功する確率が上がることがわかる。また、閾値を高く設定する(= 類似度が比較的低くても変換する)と変換に成功する確率が上がるが、同時に誤変換する確率も上がる。
今回は、誤変換と変換成功のバランスをみつつ、誤変換に気づかずそのまま記録するよりは変換できずに手動で修正する方が良いと考え(偽陽性を防ぐイメージ)、閾値を0.5に設定することとした。

今後

いったん上記の方法で類似度を出す方法を組み込んだので、しばらく利用して感触を確かめてみる。以下のあたりが次の課題だと現時点では考えている。
  • 漢字とひらがなの表記揺れへの対応(例えば「たまねぎ」と「玉ねぎ」は距離が0とならないため、より距離が短い品目があるとそちらに誤変換されてしまう)
  • 半角カタカナへの対応(上記で記載したが、半角カタカナの読み取り精度は著しく低いので、同じ品目でも読み取り結果のばらつきが大きすぎ、今回の類似度による変換はほぼ効かない)

レーベンシュタイン距離の求め方を整理する

これは、類似度を用いてレシートOCRの精度を上げるで文字列間の類似度を判定するために使用した、レーベンシュタイン距離について自分の理解のためにまとめた記事となる。

レーベンシュタイン距離とは

レーベンシュタイン距離は、文字列間の類似度を距離として示すもの(距離が近いほど類似していることを示す)。挿入・削除・置換の各操作を合計で何回行えばもう一方の文字列に変換できるかで距離を測る。
挿入・削除・置換はそれぞれ以下の操作を指す。
・挿入:1文字を挿入する e.g. たこ → たいこ
・削除:1文字を削除する e.g. たいこ → たこ
・置換:1文字を置換する e.g. たこ → たて
これらの操作を最小の回数だけ行い、もう一方の単語に変換する。
例えば「とまと」と「たまご」なら、以下のように2回置換を行うのが一番手数が少なく変換できるのでレーベンシュタイン距離は2となる(※)
「とまと」
→ とをたに変換して「たまと」
→ とをごに変換して「たまご」
※ 用途により挿入・削除・置換それぞれの重み付けを変えることもできるが、等しい重みとしている(以下も同様)。

レーベンシュタイン距離を求めるには

以下のような2次元配列を用意して、各セルに各単語のクロスする部分文字列間のレーベンシュタイン距離を入れていく。
左上は□(空文字列)と□(空文字列)のレーベンシュタイン距離で、同じ文字列なのでレーベンシュタイン距離は0と確定する。そこから右下に向かって順次求めていくと、最後に元々求めたかった文字列間のレーベンシュタイン距離が求まる。
具体的な流れを見ていくと
① 空文字との距離である0行目・0列目はそれぞれ削除と挿入により至るセルなので、それぞれ一つ上/左のセルの距離+1が入る
② ①以外のセルは、置換によってもたどり着けるセルのため、削除と挿入に加え置換も考える。置換については斜め上のセルの距離から+1あるいは+0となる。挿入・削除・置換のうち、一番短い距離がそのセルの値となる(なるべく短くなるような距離が求めたいため)。
③ 最終的に右下までたどり着くと、2単語間のレーベンシュタイン距離が求まったことになる。
※ 一般に文字列が長いほど距離が長くなるため、レーベンシュタイン距離同士を比較する際に文字列の長さで割って標準化する場合もある

Pythonでレーベンシュタイン距離を求める処理を書く

上記の流れを素直にそのままPythonで書いた。
def levenshtein(word_A, word_B):
    # 挿入・削除・置換のコストを定義しておく
    INSERT_COST = 1
    DELETE_COST = 1
    SUBSTITUTE_COST = 1

    # 2次元配列を用意しておく
    distances = []
    len_A = len(word_A)
    len_B = len(word_B)
    dp = [[0] * (len_B + 1) for _ in range(len_A + 1)]

    # 上記①の工程
    for i in range(len_A + 1):
        dp[i][0] = i * INSERT_COST
    for i in range(len_B + 1):
        dp[0][i] = i * DELETE_COST

    # 上記②の工程
    for i_A in range(1, len_A + 1):
        for i_B in range(1, len_B + 1):
            insertion = dp[i_A - 1][i_B] + INSERT_COST
            deletion = dp[i_A][i_B - 1] + DELETE_COST
            substitution = (
                dp[i_A - 1][i_B - 1]
                if word_A[i_A - 1] == word_B[i_B - 1]
                else dp[i_A - 1][i_B - 1] + SUBSTITUTE_COST
            )
            dp[i_A][i_B] = min(insertion, deletion, substitution)
    
    # 上記③の工程
    distance = dp[len_A][len_B] / max(len_A, len_B)  # 標準化している
    return distance

ワンライナーの演習用環境をDockerで作成する

1日1問、半年以内に習得 シェル・ワンライナー160本ノック という、シェルのワンライナー(その場かぎりの1行プログラム)の問題が160問載っている本を解いている。

単純にコマンドの学習だけでなくLinuxやその周辺知識も学びがあるので、自分にとっては結構難しい問題もあるのだけど、コツコツと進めている。

この記事では、こちらも学習ついでにLinuxの環境をDockerで作成し、それを使って演習問題を解いているので、その内容を記載する。

環境

・PC環境:
 macOS 10.15

・Dockerで作成する環境(書籍内で動作確認したと記載されている環境):
 Ubuntu20.04 LTS
 UTF-8を用いる日本語環境(ja_JP.UTF-8)

結論

必要なパッケージや設定等をDockerfileに記載し、それを元に作成したコンテナ内でbashを起動している。

Dockerfile

FROM ubuntu:20.04
RUN apt update && \\
    apt install -y language-pack-ja && \\
    update-locale LANG=ja_JP.UTF-8 && \\
    echo "export LANG=ja_JP.UTF-8" >> ~/.bashrc
RUN apt install -y man-db && yes | unminimize
RUN apt-get update && \\
    apt install -y vim
# 以下、必要なコマンドが出てくるたびに追加していく
RUN apt install -y gawk
RUN apt install -y imagemagick
RUN apt install -y parallel
RUN apt install rename
RUN apt install num-utils
RUN apt install -y pandoc

手順

$ docker build -t shell_oneliner .
$ docker run -it --rm -v (ホストマシン上のディレクトリのパス):(コンテナ内のマウントされるディレクトリのパス) shell_oneliner /bin/bash

詳細

Dockerfileとコマンドの内容について記載する。

日本語入力が行えるようにする

随所で日本語の文字列を扱う問題が出てくるが、初めに作成した環境では日本語の文字列を入力することができなかった。

コンテナ環境内でロケール設定を確認すると以下のようになっていた。

$ locale
LANG=
LANGUAGE=
LC_CTYPE="POSIX"
LC_NUMERIC="POSIX"
LC_TIME="POSIX"
LC_COLLATE="POSIX"
LC_MONETARY="POSIX"
LC_MESSAGES="POSIX"
LC_PAPER="POSIX"
LC_NAME="POSIX"
LC_ADDRESS="POSIX"
LC_TELEPHONE="POSIX"
LC_MEASUREMENT="POSIX"
LC_IDENTIFICATION="POSIX"
LC_ALL=

本の中で、日本語環境の設定として以下のコマンドが示されている(練習1.2.gの補足)。

$ sudo apt install language-pack-ja
$ sudo update-locale LANG=ja_JP.UTF-8

当初、これをDockerfileに組み込んだのだけどlocaleの結果が上記と同様のままで日本語入力ができるようにならなかった。以下のように起動時に読み込む.bashrcへの追記を追加したらlocaleも変わり日本語入力可能になった。

RUN apt update && \\
    apt install -y language-pack-ja && \\
    update-locale LANG=ja_JP.UTF-8 && \\
    echo "export LANG=ja_JP.UTF-8" >> ~/.bashrc
$ locale
LANG=ja_JP.UTF-8
LANGUAGE=
LC_CTYPE="ja_JP.UTF-8"
LC_NUMERIC="ja_JP.UTF-8"
LC_TIME="ja_JP.UTF-8"
LC_COLLATE="ja_JP.UTF-8"
LC_MONETARY="ja_JP.UTF-8"
LC_MESSAGES="ja_JP.UTF-8"
LC_PAPER="ja_JP.UTF-8"
LC_NAME="ja_JP.UTF-8"
LC_ADDRESS="ja_JP.UTF-8"
LC_TELEPHONE="ja_JP.UTF-8"
LC_MEASUREMENT="ja_JP.UTF-8"
LC_IDENTIFICATION="ja_JP.UTF-8"
LC_ALL=

manコマンドが使えるようにする

初めに作成した環境ではmanコマンドを使用することができなかった(下記のとおり、最低限の構成のためmanコマンドは含まれていない)。

$ man bash
This system has been minimized by removing packages and content that are
not required on a system that users do not log into.

To restore this content, including manpages, you can run the 'unminimize'
command. You will still need to ensure the 'man-db' package is installed.

上記コメントに従い、man-dbのインストールとunminimizeの実行を行う以下の1行をDockerfileに追加した。

RUN apt install -y man-db && yes | unminimize

unminimizeコマンドの実行中、y/nの選択をする必要がある。yesコマンドを挟むことで、unminimizeコマンドの実行時にy(yes)を継続的に渡してくれる。

Vimを使えるようにする

問題はワンライナーがほとんどなのだけど、時々シェルスクリプトを作成する問題もあったため、Vimを使えるようにした。

はじめRUN apt install -y vimをDockerfileに追加したのだけど、エラーになった。

Failed to fetch <http://security.ubuntu.com/ubuntu/pool/main/s/sqlite3/libsqlite3-0_3.31.1-4ubuntu0.2_amd64.deb>  404  Not Found [IP: 91.189.91.38 80]
#12 17.78 E: Unable to fetch some archives, maybe run apt-get update or try with --fix-missing?
------
executor failed running [/bin/sh -c apt install -y vim]: exit code: 100

コメントに素直に従ってrun apt-get updateを追加したら成功するようになった。(aptコマンドを使用している場合でもapt-getのupdateが必要なのか…と思ったけど、この部分は少し調べてもわからなかった)

RUN apt-get update && \\
    apt install -y vim

コマンド実行

  • Dockerイメージの作成
$ docker build -t shell_oneliner .

Dockerfileと同じディレクトリで実行する。-tをつけてイメージにshell_onelinerというイメージ名をつけている。

  • コンテナの作成・起動
$ docker run -it --rm -v (ホストマシン上のディレクトリのパス):(コンテナ内のマウントされるディレクトリのパス) shell_oneliner /bin/bash

問題に使用するファイルがGitHubのリポジトリに用意されているため、そのリポジトリをクローンして手元に持ってきて、コンテナ作成時にマウントしてコンテナ内で当該ファイルを使用できるようにした。

GitHub Actionsでlinterとformatterを実行する


GitHub Actionsでlinterとformatterが自動で実行されるようにするため、学習を兼ねて、GitHubリポジトリの環境を以下手順で改修していく。
1. パッケージ管理をPipenvからPoetryに切り替える
2. linterとformatterを導入する
3. GitHub Actionsでlinterとformatterを実行する(この記事) 前提:
・Mac OS 10.15
・Python 3.7
・対象とするリポジトリは yrarchi / household_accounts
3. GitHub Actionsでlinterとformatterを走らせる
pushされたことをトリガーにして、BlackとFlake8によるチェックを自動で実行するワークフローを作成する。

① GitHub Actions の基本的な書き方を確認する

.github/workflows/以下にYAMLファイルを作成することで、ワークフローを作成することができる。GitHub Docs: GitHub Actions / Learn GitHub Actions / Understanding GitHub Actionsを参照すると、YAMLファイルの基本的な型は以下のようになる。
name: learn-github-actions  # ワークフロー名  
on: [push]  # イベント: ワークフローを実行するトリガー
jobs:
  check-bats-version:  # ジョブ: 同じランナーで実行されるステップのセット
    runs-on: ubuntu-latest  # ランナー: ワークフローを実行するサーバ (Linux, Mac, Windows)
    steps:  # ステップ: 同じランナーで実行されるタスク
      - uses: actions/checkout@v3  # uses: アクション(繰り返されるタスク)の実行
      - uses: actions/setup-node@v3
        with:
          node-version: '14'
      - run: npm install -g bats  # run: コマンドの実行

② YAMLファイルの作成

① で確認した書き方に沿って、今回の目的に合わせて書き換えてみる。
name: CI
on:
  push:
jobs:
  lint_and_format:
    runs-on: macos-10.15
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-python@v3
        with:
          python-version: '3.7.8'
      - name: Install Poetry
        run: |
          curl -sSL https://install.python-poetry.org | python -
          echo "$HOME/.local/bin" >> $GITHUB_PATH
      - name: Install Dependencies
        run: poetry install --no-interaction
      - name: Lint with flake8
        run: poetry run flake8 .
      - name: Format with black
        run: poetry run black --check .
以下、部分ごとに分割して設定内容を記載する。
イベント: ワークフローを実行するトリガー
 on:
   push:
GitHub Docs: GitHub Actions / Using workflows / Events that trigger workflows の通りに設定した。
For example, you can run a workflow when the push event occurs.
on:
  push
・ランナー: ワークフローを実行するサーバ
  runs-on: macos-10.15
GitHub Docs: GitHub Actions / Using jobs / Choosing the runner for a job に使用可能なランナーの種類として、Windows / Ubuntu / MacOS が記載されている。今回GitHub Actionsを設定するリポジトリは、Mac環境上で動くデスクトップアプリのためMacを選択した。 ・ステップ: 同じランナーで実行されるタスク
   steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-python@v3
        with:
          python-version: '3.7.8'
↑ リポジトリをcheckoutし、Python環境を作成している。
actions/checkout@v3
actions/setup-python@v3
      - name: Install Poetry
        run: |
          curl -sSL https://install.python-poetry.org | python -
          echo "$HOME/.local/bin" >> $GITHUB_PATH
      - name: Install Dependencies
        run: poetry install --no-interaction
↑ Poetryをインストールした上で、必要なライブラリのインストールを行っている。 echo "$HOME/.local/bin" >> $GITHUB_PATH は、Poetryのインストールされた /Users/runner/.local/bin$GITHUB_PATH に設定している。poetry install を行うステップと分けるのは、GitHub Docs: GitHub Actions / Using workflows / Workflow commands に記載の以下の理由のため必要と理解した。
Prepends a directory to the system PATH variable and automatically makes it available to all subsequent actions in the current job; the currently running action cannot access the updated path variable.
      - name: Lint with flake8
        run: poetry run flake8 .
      - name: Format with black
        run: poetry run black --check .
↑ Flake8 と Black を実行している。

③ pushしてワークフローが実行されるか試してみる

git push するとワークフローが実行された。ActionsタブでGitHub Actions の実行結果を確認することができる。
各ステップの名称をクリックすると、その詳細を確認することができる。例えば Format with black をクリックすると、poetry run black –check . を実行した結果を確認できる。
Run poetry run black --check .
Skipping .ipynb files as Jupyter dependencies are not installed.
You can fix this by running ``pip install black[jupyter]``
All done! ✨ 🍰 ✨
13 files would be left unchanged.
以上で、当初の目的であったGitHub Actionsでlinterとformatterを実行する設定をすることができた。

linterとformatterを導入する


GitHub Actionsでlinterとformatterが自動で実行されるようにするため、学習を兼ねて、GitHubリポジトリの環境を以下手順で改修していく。
1. パッケージ管理をPipenvからPoetryに切り替える
2. linterとformatterを導入する(この記事)
3. GitHub Actionsでlinterとformatterを実行する 前提:
・Mac OS 10.15
・Python 3.7
・対象とするリポジトリは yrarchi / household_accounts
2. linterとformatterを導入する
linterとしてFlake8、formatterとしてBlackをインストールしていく。

① Blackのインストール

Black: Installation を参照しつつ、PipenvからPoetryに切り替える でPoetryを使うように切り替えたため、Poetryでインストールする。
$poetry add --dev black
Using version ^22.3.0 for black

Updating dependencies
Resolving dependencies... (14.8s)

Writing lock file

Package operations: 7 installs, 0 updates, 0 removals

  • Installing click (8.1.3)
  • Installing mypy-extensions (0.4.3)
  • Installing pathspec (0.9.0)
  • Installing platformdirs (2.5.2)
  • Installing tomli (2.0.1)
  • Installing typed-ast (1.5.3)
  • Installing black (22.3.0)
インストール後、pyproject.tomlに追加されているのを確認した。
[tool.poetry.dev-dependencies]
…
black = "^22.3.0"

② Blackの動きを確認する

①でインストールしたので、どのように実行されるか試してみる。
Black: The basics / Usage / Writeback and reporting
By default Black reformats the files given and/or found in place. Sometimes you need Black to just tell you what it would do without actually rewriting the Python files.
There’s two variations to this mode that are independently enabled by their respective flags. Both variations can be enabled at once.
Blackは通常フォーマットしてしまうけど、チェックのみかける方法として2つの方法が記載されていたので、これを試してみる。
・1つ目の方法: –check
Passing --check will make Black exit with:
・ code 0 if nothing would change;
・ code 1 if some files would be reformatted; or
・ code 123 if there was an internal error
実際に実行してみる。
$poetry run black household_accounts/calc.py --check
would reformat household_accounts/calc.py
Oh no! 💥 💔 💥
1 file would be reformatted.

$echo $?
1
Blackのルールに引っかかる箇所があったため、実行するとフォーマットされる旨が出ており、終了ステータスは1となっている。
・2つ目の方法:–diff
Passing --diff will make Black print out diffs that indicate what changes Black would’ve made. They are printed to stdout so capturing them is simple.
実際に実行してみる。
$poetry run black household_accounts/calc.py --diff
--- household_accounts/calc.py  2021-05-09 08:22:51.153728 +0000
+++ household_accounts/calc.py  2022-05-04 02:00:42.554375 +0000
@@ -1,27 +1,34 @@
 import re
 import config

-def calc_price_tax_in(price_list, discount_list, reduced_tax_rate_flg_list, tax_excluded_flg):
+
+def calc_price_tax_in(
+    price_list, discount_list, reduced_tax_rate_flg_list, tax_excluded_flg
+):
(中略)

All done! ✨ 🍰 ✨
1 file would be reformatted.
実際に実行した場合に修正される箇所を確認することができる。

③ Flake8のインストール

Flake8: Quickstart / Installation を参照しつつ、PipenvからPoetryに切り替える でPoetryを使うように切り替えたため、Poetryでインストールする。
$poetry add --dev flake8
Using version ^4.0.1 for flake8

Updating dependencies
Resolving dependencies... (0.5s)

Writing lock file

Package operations: 4 installs, 0 updates, 0 removals

  • Installing mccabe (0.6.1)
  • Installing pycodestyle (2.8.0)
  • Installing pyflakes (2.4.0)
  • Installing flake8 (4.0.1)

④ Flake8の動きを確認する

実際にFlake8を動かしてみる。
$poetry run flake8 household_accounts/calc.py
household_accounts/calc.py:12:80: E501 line too long (87 > 79 characters)
household_accounts/calc.py:25:80: E501 line too long (80 > 79 characters)
household_accounts/calc.py:32:80: E501 line too long (86 > 79 characters)
1行が長すぎると出た。Black: Code style / Line lengh を参照して、Flake8と併用する場合のおすすめ設定に倣い、setup.cfgを以下のように設定した。
[flake8] 
max-line-length = 88 
extend-ignore = E203
また、$poetry run flake8 . と特定のファイルでなく全体に対してFlake8を走らせたところ、時間がかかり.venvの中にも指摘が大量に出た。そのため、Flake8: Options and their Descriptions / –exclude を参照してFlake8の対象外とするファイルの設定を追加した。
[flake8] 
max-line-length = 88 
extend-ignore = E203 
exclude = .venv/  # これを追加
以上で、Flake8とBlackが使えるようになった。次にこれらを自動で実行するようにGitHub Actionsを設定していく。→ GitHub Actionsでlinterとformatterを実行する

PipenvからPoetryに切り替える


GitHub Actionsでlinterとformatterが自動で実行されるようにするため、学習を兼ねてGitHubリポジトリの環境を以下手順で改修していく。
1. パッケージ管理をPipenvからPoetryに切り替える(この記事)
2. linterとformatterを導入する
3. GitHub Actionsでlinterとformatterを実行する 前提:
・Mac OS 10.15
・Python 3.7
・対象とするリポジトリは yrarchi / household_accounts
1. パッケージ管理をPipenvからPoetryに切り替える
Pythonのパッケージ管理ツールとしてPipenvを使用していたが、Pipfile.lockの生成に時間がかかることが多かったためPoetryに切り替えることにする。
PipenvからPoetryへ切り替える際に行った手順を記載していく。

① Poetryのインストール

当初、以下を参照してインストールを実行した。
Poetry: Introduction / Installation
$ curl -sSL https://raw.githubusercontent.com/python-poetry/poetry/master/get-poetry.py | python -
結果、インストール自体はできたのだが、This installer is deprecated. と表示されたため、アンインストールして、同じくPoetryのドキュメント(master版) 記載の別の方法でインストールをし直した。
Poetry: Introduction / Installation
$ curl -sSL https://install.python-poetry.org | python3 -
bash_profileを確認すると、パスに自動で追加されていた。
export PATH="$HOME/.poetry/bin:$PATH"
以下が実行できたのでpoetryコマンドを使える状態になったようだ。
$ poetry --version
Poetry version 1.1.13

② Poetryの設定

仮想環境をプロジェクトのルートディレクトリに作るようにしたいので、以下を参照して設定しておく。
Poetry: Configuration / Available settings / virtualenvs.in-project
Create the virtualenv inside the project’s root directory. Defaults to None.
If set to true, the virtualenv will be created and expected in a folder named .venv within the root directory of the project.
To change or otherwise add a new configuration setting, you can pass a value after the setting’s name:
poetry config virtualenvs.path /path/to/cache/directory/virtualenvs
以下のコマンドを実行する。
$ poetry config virtualenvs.in-project true
設定できたことを以下で確認した。
$ poetry config virtualenvs.in-project
true

③ ライブラリのインストール

すでにある環境からPoetryに乗り換える方法が記載されていたので、その方法を参照して進めていく。
Poetry: Basic usage / Initialising a pre-existing project
Instead of creating a new project, Poetry can be used to ‘initialise’ a pre-populated directory. To interactively create a pyproject.toml file in directory pre-existing-project:
cd pre-existing-project 
poetry init
poetry init を実行すると、パッケージ名やライセンス等、順に聞かれていくのでそれに答えていくとpyproject.tomlが作られた。
$poetry init

This command will guide you through creating your pyproject.toml config.

Package name [household_accounts]:
Version [0.1.0]:
(後略)
$poetry install
Creating virtualenv household-accounts in /Users/username/src/household_accounts/.venv
Updating dependencies
Resolving dependencies... (0.1s)

Writing lock file

Installing the current project: household_accounts (0.1.0)
次に、以下を参照して使用しているライブラリ類をインストールした。 Poetry: Basic usage / Specifying dependencies
instead of modifying the pyproject.toml file by hand, you can use the add command.
$ poetry add pendulum
$poetry add pyocr==0.8
$poetry add --dev jupyterlab==3.2.9  # 開発用のパッケージは--devをつける

④ Pipenvのアンインストール

Pipenvはhomebrewでインストールしていたので、以下でアンインストールを行った。
$brew uninstall pipenv
Uninstalling /usr/local/Cellar/pipenv/2022.4.21... (2,654 files, 39.6MB)
Pipenvのアンインストール後、Pipenvでのみ使用していた依存パッケージが残っていたため、それらもアンインストールした。 ※ 依存関係の確認は以下のコマンドで行った
$brew deps --tree pipenv  # pipenvの依存パッケージ
pipenv
├── python@3.10
│   ├── gdbm
│   ├── mpdecimal
│   ├── openssl@1.1
│   │   └── ca-certificates
│   ├── readline
│   ├── sqlite
│   │   └── readline
│   └── xz
└── six

$brew uses --installed python@3.10  # python@3.10に依存しているパッケージ
pipenv
以上でPipenvからPoetryに切り替えが終了したので、次に元々やりたかったlinerとformatterの導入を行っていく。→ linterとformatterを導入する

BigQuery: TIMESTAMPに対し日付を指定する際の間違い事例集

BigQueryでTIMESTAMPのカラムに対し日付を指定する際に、自分が今まで誤って行った方法をまとめてみる。

課題設定

startTime というTIMESTAMP型のカラムがあるテーブルを対象として、
startTime
----------
2016-01-01 01:00:00 UTC
2013-04-21 11:33:01 UTC
2019-09-27 10:30:20 UTC
…
startTimeが2016年7月1日のレコードのみを取り出したいと想定する。
SELECT startTime, … 
FROM dataset.table
WHERE [  ?  ]  // 2016-07-01のデータのみ取り出したい場合に、どう書くのが適切なのか
(話を単純にするため、UTCからの変換は考慮に入れる必要がないと仮定する) ものすごく単純な課題にもかかわらず、色々踏み抜いてきたので以下一つずつ書いていく。

失敗例1:

日付である 2016-07-01 を指定するぞ、という意識だけで以下のクエリをまず書いた。
WHERE startTime = date(2016, 7, 1)
※ BigQueryのDATE型での特定の日時の書き方は以下を参照:
BigQueryリファレンス: Date functions 対象のstartTimeがTIMESTAMP型だという認識がそもそも持てていない時の失敗。( No matching signature for operator = for argument types: TIMESTAMP, DATE. と怒られる)

失敗例2:

比較対象の型に合わせないといけないのだった、DATEからTIMESTAMPに変えよう…となって、次に以下のようなクエリを書いた。
WHERE startTime = timestamp('2016-07-01')
※ BigQueryでのTIMESTAMP型での特定の日時の書き方は以下を参照:
BigQueryリファレンス: Timestamp functions これは実行できてしまうのだけど、意図した形では動かない。 timestamp('2016-07-01')2016-07-01 00:00:00 UTCのことなので、00時ちょうどのデータしか合致しない。結果が1行も返ってこないか、想定よりとても少ない件数が返ってくることになる(count などで気づければよいのだけど、処理途中の部分だったりすると気づけないことがある)。

失敗例3:

では時刻まで含めた形で指定すれば良いのでは、となって以下のクエリを書いた。
WHERE startTime BETWEEN timestamp('2016-07-01 00:00:00') AND timestamp('2016-07-01 23:59:59')
2016/07/01 のレコードの大半がこれで取得できるけど、正確ではない(終了側の指定の仕方が適切ではない)。 TIMESTAMP の説明として以下の記載がある。
BigQueryリファレンス: Data-types
A TIMESTAMP object represents an absolute point in time, independent of any time zone or convention such as Daylight Savings Time with microsecond precision.
TIMESTAMPはマイクロ秒の精度であることがわかる。範囲としては 0001-01-01 00:00:00 to 9999-12-31 23:59:59.999999 UTCとある。 そのため、記載したクエリだと2016-07-01の23時59分59.5秒のデータなどが範囲に含まれないことになってしまう。

失敗例4:

ではTIMESTAMPの方をDATE型に変換してしまうか、となって以下のクエリを書いた。
WHERE date(startTime) = date(2016, 07, 01)
DATE(timestamp_expression)という形式でTIMESTAMPをDATEに変換できる BigQueryリファレンス: Date funcitons これでも2016/07/01のレコードを取得できる。ただ、これがパーティション分割テーブルだったりすると、全てスキャンされることになりパフォーマンスが下がる。 ※ パーティション分割テーブルのスキャン範囲についてはこのあたりに記載がある
BigQueryリファレンス: パーティション分割テーブルに対するクエリ
クエリでスキャンされるパーティションを制限するには、フィルタで定数式を使用します。クエリフィルタで動的式を使用すると、BigQuery はすべてのパーティションをスキャンする必要があります。

成功例:

数々の失敗を経て以下の形式にたどり着いた。
WHERE 
  startTime >= timestamp('2016-07-01 00:00:00') 
  AND startTime < timestamp('2016-07-02 00:00:00')
これだと、意図した範囲を指定できているし、定数式にもなっている。
(この条件を満たす他の書き方もあるのかもしれないけど…) 1つのネタでよくこれだけ失敗できたなという感があるけど、色々回り道したことで理解が深まったのでよかったと思うことにする。

BigQueryへのデータ読み込み_3.Cloud Data FusionでCSVを整形する

前の記事では、Cloud Functionsを使い、Cloud StorageにCSVファイルが追加されたらBigQueryに自動で読み込むことをしてみた。この際、CSVは予め手動で整形した上でCloud Storageに置いていた。
この記事では、CSVをBigQueryに読み込み可能な状態に整形する過程を自動化する方法を探る。

概要

今回、Cloud Data Fusionを用いてCSV整形を実施してみた。
Cloud Data Fusionは、データパイプラインをGUIで作成できるサービスで、ETLのTransform部分も様々な機能が用意されているため、CSVの整形もGUIで行えてしまう。
今回は、以下の2段階で進めた手順をまとめた。
A. 定型的にCSVを整形し、BigQueryに読み込む手順をCloud Data Fusionで作成する
B. 任意のCSVファイル名を渡したらA.が実行されるようにする

事前準備

クイックスタート|Cloud Data Fusion ドキュメント を参照して、APIを有効にし、インスタンスを作成しておく。
下記のように、インスタンス名の前に緑のチェックマークが入ったら作成完了。
操作方法や概観をつかむため、事前に以下のチュートリアルをやった。
ターゲティングキャンペーンパイプライン | Cloud Data Fusion ドキュメント 顧客一覧から、住所が特定の条件に合致する顧客情報のみを抜き出すという内容。今回の目的のうち、CSVの整形を行う部分で参考になった。
再利用可能なパイプラインの作成 | Cloud Data Fusion ドキュメント 引数で情報を渡すよう設定し、再利用可能なパイプラインを構築するという内容。今回の目的のうち、CSVのファイル名を与えたら実行されるよう設定しておく部分で参考になった。

手順

A. 定型的にCSVを整形し、BigQueryに読み込む手順をCloud Data Fusionで作成する

スタートとゴール

前の記事同様、気象庁のデータを使用する。
気象庁のHPからダウンロードした段階では、以下のような形式になっている。このCSVをCloud Storageの特定のフォルダに置いた状態からスタートする。
ダウンロードした時刻:2021/11/21 16:31:04

,東京,東京,東京,東京,東京,東京
年月日,平均気温(℃),最高気温(℃),最低気温(℃),最大風速(m/s),最大風速(m/s),天気概況(昼:06時~18時)
,,,,,風向,
2021/1/1,4.4,10.5,-1.3,3.1,北北東,快晴
2021/1/2,4.8,10.8,0.1,4.7,北北東,快晴
これを下記の形式にData Fusion内で整形し、BigQueryに格納することを目指す。
date,ave_temp,max_temp,min_temp,max_wind_speed,wind_direction,weather
2021-01-01,4.4,10.5,-1.3,3.1,北北東,快晴
2021-01-02,4.8,10.8,0.1,4.7,北北東,快晴

読み込み先のBigQueryの準備をしておく

BigQueryに読み込み先のデータセットとテーブルを準備しておく。 スキーマはこれからCloud Data Fusionで整形する予定の内容に合わせておく。

② Cloud Data Fusion UIでCSVを読み込む

「事前準備」でインスタンスを作成してあるので、アクションの「インスタンスを表示」をクリックすると、UIに画面が遷移する。
Wrangleをクリックする。
Upload元としてCloud Storageを選択し、用意しておいたCSVを読み込むと、以下のような感じで表示される。行によってカンマの数が異なるデータのため、うまくカンマ区切りで読み込まれず、全て1列に入る形になっている。

③ データを整形する

ゴールの内容に合うようにデータを整形していく。
  • 不要なヘッダーの削除
    今回は冒頭の5行を消してしまい、列名は後から付け直す方針で進める。 Filter > value matches regex で正規表現により日付から始まる列のみ残すように(冒頭の5行を削除するように)した。
  • カンマ区切りで区切る Parse > CSV を選択し、区切り文字はCommaを選択する。
ここまでの処理でこのような形式になっている。
  • 不要な列を削除する
    カラム区切りで分割した際、分割前の状態も列として残っていている。不要なため Delete columnより削除する。
  • 列名をつける
    各列打ち替えていく。
  • 型変換
    • 日付:読み込んだ時点では1行全体で1つの文字列として判断されているため、現状日付も文字列となっている。Parse > Simple date より、今回2021/1/1形式だったのでCustom formatを選択し、yyyy/m/dで実行した。
すると、不要な時間までついてきてしまった。
公式のドキュメントでは見つけられなかったのだけど、stack overflowを参照して、Custom transform > date.toLocalDate()をかけたら、Date型になってくれた。
  • 数値:Change data type > Floatを選択して変換する。
Wranglerで行った各変換は画面右で表示されており、不要な処理を削除することが可能。

④パイプライン作成

Create a Pipeline をクリックし、Batch pipelineを選択すると
データ元のGCSと設定したWranglerがパイプラインでつながった状態で表示される。

⑤ データの書き出し先を設定する(BigQuery)

Sink > BigQueryを選択(Sinkはデータの出力先)してBigQueryのノードを追加し、Wranglerと接続する。
ポインタをBigQuryノードの上に置くとPropertiesが表示されるので、クリックする。BigQuery側のデータセットやテーブル等の指定を行う。

⑥ デプロイと実行

Runをクリックすると、処理が進んでいく(けっこう時間がかかった 20分程度)。Statusの部分の表示で処理の段階がわかり、無事成功すると最終的にSucceededになる。

B. 任意のCSVファイル名を渡したらA.が実行されるようにする

A. では、元となるCSVファイル名をベタ打ちで指定していた。これだとファイル名が変わるたびに毎回作り直す必要があるので、任意のファイル名を渡して処理を実行できるようにしたい。
A. の処理をベースにし、一部書き換える。

① データの読み込み先の変更

Cloud Storageからの読み込んでいる最初のノードのPropertiesをクリックし、読み込み先のパスの指定部分末尾のCSVファイル名が入る部分を${csv_file_name}に変更する。
※ 下図のFormat部分をcsvとしているが、ここは今回textにする必要があった(csvだと1行目しか読み込まれなかった。おそらく、今回のCSVファイルがCSVと判断される形式でなかったのが要因)

② デプロイ

変数を入れてRunしてみると、無事成功した。

未解決な部分

今回文字列として読み込まれた小数点の数値列について、文字列からFloatに変換すると元々存在しなかった不要な桁が出てしまったが、これをうまく処理することができなかった。
(Wranglerの処理のプレビュー画面 不要な桁が残る処理となっている)
round関数があるので、wranglerでround(ave_temp, 1)などと試してみたが、エラーになり、roundの桁数を指定する方法が見つけられなかった。
この記事の中で、
We could write the recipe for the transformation directly with Directives using JEXL syntax(https://commons.apache.org/proper/commons-jexl/reference/syntax.html)
とあったので、該当ページを参照したが、今回行いたい操作のヒントは得ることができなかった。

まとめ

今回、Data Fusionを利用してCSVファイルの整形を行ってみた。GUIで様々な処理が行えるのはとっつきやすくはあったが、少し込み入った処理をしようとするとやりづらかったり、結局学習コストがかかるので、コードを書いて管理する方が楽かもしれないと感じた。
次は、ワークフローを管理できるCloud Composerを触ってみたい。

OpenCVを利用した矩形検出の試行錯誤_エッジ検出・適応的閾値処理

前記事で示したOpenCVを用いた矩形検出の改善案のうち、ここでは下記2案の内容について記載する。
改善案3 エッジ検出を使う
改善案4 適応的閾値処理を使う

環境

Python 3.7.8
OpenCV-Python 4.5.1.48

改善案3 エッジ検出を使う

案の概要

画像の輝度が急激に変化している箇所をエッジ(今回だと輪郭)として捉える処理を行い、エッジのみになった画像に対して輪郭検知を行うという案。
エッジとみなすのは周辺と比べ値が急激に変化している箇所なので、微分した値が大きい箇所をエッジとみなす形で処理が行われる。調べると、エッジ検出としてはSobelフィルタ、Laplacianフィルタ、Cannyフィルタなどがよく使われているようだったが、ここではCannyフィルタを利用した場合の結果を示す。

案の実践

エッジ検出した結果に対して輪郭の検知を行った。 一見うまくいくように見えるが、実際にはその後矩形のみに絞る処理がうまくいかず、1枚もレシートを検出できなかった。これは、レシートの外形を輪郭が囲っているように見えるがそれらはひとつながりの線になっておらず、別々の矩形と捉えられているからと思われる。
エッジ検出の拡大

そこで輪郭の検知前にノイズ処理(ここではモルフォロジー変換)を追加し、検出したエッジを単純化(膨張処理)することにした。 概ねレシートの輪郭を捉えられているように見えるが、一部途切れているなどしていて、その後レシートの矩形のみに絞る処理をすると1枚のレシートしか検出できなかった。
そこで、エッジの膨張させる程度をもう少し強くしてみる。 これだと3枚ともレシートを検出できた。
検出結果を見ると、レシートを囲む2重線のうち、内側のみ矩形と判断されていた。これは、外側のレシートを囲む線は背景のノイズを拾った線とつながるなどしていて、矩形と判定されなかったことによる。
一方、内側のレシートを囲む線はレシートの印字の輪郭線とつながっていないため、矩形と判断できた。今回の膨張の程度だと問題ないが、もう少し大きくすると今度はレシートの印字の検知の範囲とレシートを囲む内側の線がくっついてしまい、うまく矩形検出されなくなることが予想される。
輪郭検知の拡大

※ モルフォロジー変換を膨張でなくクロージング(エッジをいったん膨張させて他の細かいエッジと結合させた後、膨張を戻す)にすれば上記の問題が解決できるのではと考え試してみた。しかし、以下の結果となり、最終的にレシートの矩形はうまくいかなかった。

案の評価

レシートの矩形検出自体は行えるが、上記のようにモルフォロジー変換の強さを各画像に合わせ調整する必要がありそうなので、任意の画像に対応するのは難しいだろう。

コード

案3の一連の処理を行う際に書いたコードを以下に示す。

※ 案3に特有の部分
エッジ処理(Canny法)は cv2.Canny で行っている。
Pythonbinary_img = cv2.Canny(gray_img, 100, 200) # 100はminVal、200はmaxVal
# 画素値の微分値が maxVal 以上であればエッジとみなす  
# 画素値の微分値が minVal 以下であればエッジではないとみなし除外する
# 画素値の微分値が二つの閾値の間の場合、エッジと区別された画素(maxVal以上)につながっていればエッジとみなし,そうでなければエッジではないとみなし除外する
モルフォロジー変換(膨張)は cv2.dilate で行っている。
kernel = np.ones((30,30), np.uint8) # 処理の際参照する領域のサイズ
dilation = cv2.dilate(img, kernel, iterations=1) # iterationsは処理回数

一連の処理を行い、検出結果を画像で返すところまでのコード
import cv2
import numpy as np
from matplotlib import pyplot as plt


def binarize(img):
    """画像を2値化する
    """
    gray_img = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
    binary_img = cv2.Canny(gray_img, 100, 200)
    plot_img(binary_img, 'binary_img')
    binary_img = binary_img.astype('uint8')
    return binary_img


def noise_reduction(img):
    """ノイズ処理(膨張)を行う
    """
    kernel = np.ones((30,30), np.uint8)
    dilation = cv2.dilate(img, kernel, iterations=1)
    plot_img(dilation, 'dilation')
    return dilation


def find_contours(img):
    """輪郭の一覧を得る
    """
    contours, _ = cv2.findContours(img, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)
    return contours


def approximate_contours(img, contours):
    """輪郭を条件で絞り込んで矩形のみにする
    """
    height, width, _ = img.shape
    img_size = height * width
    approx_contours = []
    for i, cnt in enumerate(contours):
        arclen = cv2.arcLength(cnt, True)
        area = cv2.contourArea(cnt)
        if arclen != 0 and img_size*0.02 < area < img_size*0.9:
            approx_contour = cv2.approxPolyDP(cnt, epsilon=0.01*arclen, closed=True)
            if len(approx_contour) == 4:
                approx_contours.append(approx_contour)
    return approx_contours


def draw_contours(img, contours, file_name):
    """輪郭を画像に書き込む
    """
    draw_contours_file = cv2.drawContours(img.copy(), contours, -1, (0, 0, 255, 255), 10)
    plot_img(draw_contours_file, file_name)


def plot_img(img, file_name):
    """画像の書き出し
    """
    plt.imshow(cv2.cvtColor(img, cv2.COLOR_BGR2RGB))
    plt.title(file_name)
    plt.show()
    cv2.imwrite('./{}.png'.format(file_name), img)


def get_receipt_contours(img):
    """矩形検出までの一連の処理を行う
    """
    binary_img = binarize(img)
    noise_reduction_binary_img = noise_reduction(binary_img)
    contours = find_contours(noise_reduction_binary_img)
    approx_contours = approximate_contours(img, contours)
    draw_contours(img, contours, 'draw_all_contours')
    draw_contours(img, approx_contours, 'draw_rectangle_contours')


input_file = cv2.imread('/path/to/example.jpg')
get_receipt_contours(input_file)

改善案4 適応的閾値処理を使う

案の概要

前記事で示した現在の検出手順では、画像全体の画素値を対象として計算し2値化の閾値を決めていた。適応的閾値処理は画像全体ではなく、画像中の小領域ごとに閾値を計算する方法になる。そのため、領域ごとに光源環境が変わるような画像であっても限られた領域内の画素を対象とすることで、画像全体を対象とした場合よりも良い結果が得られる。

案の実践

OpenCVで用意されている適応的閾値処理の関数として cv2.adaptiveThreshold がある。以下の2引数を動かして変化を確認した。
・Block Size: 閾値計算時に対象にする小領域の大きさ(奇数とする)
・C: 計算された閾値から引く定数

Block Sizeを動かす(Cは2に固定)
Cを動かす(Block Sizeは101に固定)
上記結果をみて、Bloce Size=255、C=2でやってみた。 レシート3枚拾えているが、余計な背景も矩形として拾ってしまっている。
輪郭検知の段階でかなりノイズが多いことが要因と考え、ノイズ処理(中央値フィルタ)を追加してみた。 今度はレシートのみ過不足なく拾えている。

案の評価

上記のように結果を見ながら閾値を調整したので、画像によってはレシートの矩形検出がうまく行えない恐れがある。

コード

案4の一連の処理を行う際に書いたコードを以下に示す。

※ 案4に特有の部分
適応的閾値処理の関数として cv2.adaptiveThreshold を利用している。
binary_img = cv2.adaptiveThreshold(gray_img, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 255, 2)
# 第2引数:輝度値の最大値(今回255)
# 第3引数:閾値計算の方法(今回のADAPTIVE_THRESHOLD_GAUSSIAN_Cだと小領域で閾値を計算する方法にガウス分布による重み付けをした平均値を使うことになる)
# 第4引数:閾値処理の種類(今回のTHRESH_BINARYだと閾値より小さい範囲は黒大きい範囲は白に変換する)
# 第5引数:閾値計算時に対象にする小領域の大きさ(今回255としている)
# 第6引数:計算された閾値から引く定数
ノイズ処理(中央値フィルタ)として cv2.medianBlur を利用している。
median = cv2.medianBlur(img, 9) # 9はカーネルサイズ(中央値を計算する対象とする範囲)
一連の処理を行い、検出結果を画像で返すところまでのコード
import cv2
import numpy as np
from matplotlib import pyplot as plt


def binarize(img):
    """画像を2値化する
    """
    gray_img = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
    binary_img = cv2.adaptiveThreshold(gray_img, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 255, 2)
    plot_img(binary_img, 'binary_img')
    return binary_img


def noise_reduction(img):
    """ノイズ処理(中央値フィルタ)を行う
    """
    median = cv2.medianBlur(img, 9)
    plot_img(median, 'median')
    return median


def find_contours(img):
    """輪郭の一覧を得る
    """
    contours, _ = cv2.findContours(img, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)
    return contours


def approximate_contours(img, contours):
    """輪郭を条件で絞り込んで矩形のみにする
    """
    height, width, _ = img.shape
    img_size = height * width
    approx_contours = []
    for i, cnt in enumerate(contours):
        arclen = cv2.arcLength(cnt, True)
        area = cv2.contourArea(cnt)
        if arclen != 0 and img_size*0.02 < area < img_size*0.9:
            approx_contour = cv2.approxPolyDP(cnt, epsilon=0.05*arclen, closed=True)
            if len(approx_contour) == 4:
                approx_contours.append(approx_contour)
    return approx_contours


def draw_contours(img, contours, file_name):
    """輪郭を画像に書き込む
    """
    draw_contours_file = cv2.drawContours(img.copy(), contours, -1, (0, 0, 255, 255), 10)
    plot_img(draw_contours_file, file_name)


def plot_img(img, file_name):
    """画像の書き出し
    """
    plt.imshow(cv2.cvtColor(img, cv2.COLOR_BGR2RGB))
    plt.title(file_name)
    plt.show()
    cv2.imwrite('./{}.png'.format(file_name), img)


def get_receipt_contours(img):
    """矩形検出までの一連の処理を行う
    """
    binary_img = binarize(img)
    binary_img = noise_reduction(binary_img)
    contours = find_contours(binary_img)
    approx_contours = approximate_contours(img, contours)
    draw_contours(img, contours, 'draw_all_contours')
    draw_contours(img, approx_contours, 'draw_rectangle_contours')


input_file = cv2.imread('/path/to/example.jpg')
get_receipt_contours(input_file)

OpenCVを利用した矩形検出の試行錯誤_減色・色空間の変更

前記事で示したOpenCVを用いた矩形検出の改善案のうち、ここでは下記2案の内容について記載する。レシートを白・背景を黒に2値化できるような変換を目指す。
改善案1 減色する
改善案2 HSV色空間にする

環境

Python 3.7.8
OpenCV-Python 4.5.1.48

改善案1 減色する


案の概要

前記事で示した現在の検出手順では、2値化に大津の2値化を利用している。大津の2値化は画素値の分散を用いて閾値が決められるため、画素値をヒストグラムで表した時に双峰性を持つような分布になる画像だとうまく閾値を決めることができる(と理解している)。
今回の画像のヒストグラムを見てみると、山がいくつもある。また、大津の2値化による閾値は107だったが、背景で白飛びしている箇所(画像右下)が閾値より右(白色側)に入っているためうまくレシートと背景を2値化で分離できていない状態になっている。
今回の画像の画素値のヒストグラム

単純に考えると、2値化する際に背景の机が黒と判定されれば(ヒストグラムで背景が白色側の山に入らず、黒色側の山に入るように調整できれば)うまくいくはず。
単純化すれば扱う画像は以下の3色で構成されている。
・レシート:白
・背景:任意の単一色(グレースケールだと灰)
・レシートの印字:黒
この3色に減色できている画像をグレースケールに変換すれば、ヒストグラムの山が3つになる。その状態で2値化し、レシート(白)と背景(灰)の間で閾値が引かれればうまくいくという案となる(仮に期待通りに減色できたとしても、背景(灰)と印字(黒)の間で閾値が引かれてしまうという問題は残っているが、背景が1色になれば少なくとも背景が白黒両方に分布することはなくなるはず)。

案の実践

減色はk-meansを用いて行うことができる。(考えてみれば当然だけど、色は3つの数字の組で表されているので、3次元空間でクラスタリングするのと同じことだった)
概要に記載した3色に減色を試してみると以下のようになった。 そう思い通りにはいかず、背景が複数色に分かれ、かつ背景の一部はレシートと同色になる結果だった。
減色後のヒストグラムを確認すると、背景は1色にならず3色いずれにも分布している状態だった。
3色に減色した場合のヒストグラム
レシートと背景が同化しないよう、色数(クラスタリング数)を少し増やして5色にすると以下の結果だった。 5色に増やすと、3色の時にはレシートと同色に分類された画像右下部分に関してもレシートと別の色に分けられている。しかし、閾値が期待した位置で引けていないため、結局2値化した際にレシートと背景の一部が同化している。 そこで、2値化の閾値を人間が与える形に変えてみる。何色に減色したかによるが、x色に減色したうちレシートは白側上位1色か2色に属することが多いだろうから、2値化の閾値を白側から2色と3色の間の位置とするルールにしてみた。
※ 以下は7色に減色し、閾値は白側から2色と3色の間に引いた場合 これだと、一応レシートが全て無事検出できた。

案の評価

「レシートは減色したx色のうち白側上位1色か2色になる」という仮定の元、閾値を任意で定めている。そのため、レシートが白側上位3色になっている場合、あるいは白側上位1、2位に背景も含まれてしまっている場合、この案は役に立たなくなる。特に背景が白色系だと背景とレシートをうまく分離できないだろう。

コード

案1の一連の処理を行う際に書いたコードを以下に示す。

※ 案1に特有の部分
 減色は cv2.kmeans を利用している。
pixels = img.reshape(-1, 3).astype(np.float32) # 画像の変換(np.float32型で渡す必要がある)
criteria = cv2.TERM_CRITERIA_MAX_ITER + cv2.TERM_CRITERIA_EPS, 10, 1.0 # 繰り返しの終了条件 これだと精度が1に達するor繰り返し10回いずれかに達したら終了する
attempts = 10 # k-meansの初期値の試行回数
flags = cv2.KMEANS_RANDOM_CENTERS # k-meansの重心の初期値の決め方
_, labels, centers = cv2.kmeans(pixels, K, None, criteria, attempts, flags)
一連の処理を行い、検出結果を画像で返すところまでのコード
import cv2
import numpy as np
from matplotlib import pyplot as plt


def sub_color(img, K):
    """色数を指定して減色する
    """
    pixels = img.reshape(-1, 3).astype(np.float32)
    criteria = cv2.TERM_CRITERIA_MAX_ITER + cv2.TERM_CRITERIA_EPS, 10, 1.0
    attempts = 10
    flags = cv2.KMEANS_RANDOM_CENTERS
    _, labels, centers = cv2.kmeans(pixels, K, None, criteria, attempts, flags)
    sub_color_img = centers[labels].reshape(img.shape).astype(np.uint8)
    plot_img(sub_color_img, 'sub_color_img')
    return sub_color_img


def plot_histgram(img):
    """画像の画素値の分布をヒストグラムにする
    """
    hist = cv2.calcHist([img], [0], None, [256], [0,256])
    plt.bar([i for i in range(0,256)], hist.ravel())
    plt.show()


def binarize(img):
    """画像を2値化する
    """
    gray_img = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
    plot_img(gray_img, 'gray_img')
    threshold = np.unique(np.array(gray_img).ravel())[-2] -1  # 白側から2色と3色の間の位置を閾値とする
    _, binary_img = cv2.threshold(gray_img, threshold, 255, cv2.THRESH_BINARY)
    plot_img(binary_img, 'binary_img')
    return gray_img, binary_img


def find_contours(img):
    """輪郭の一覧を得る
    """
    contours, _ = cv2.findContours(img, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)
    return contours


def approximate_contours(img, contours):
    """輪郭を条件で絞り込んで矩形のみにする
    """
    height, width, _ = img.shape
    img_size = height * width
    approx_contours = []
    for i, cnt in enumerate(contours):
        arclen = cv2.arcLength(cnt, True)
        area = cv2.contourArea(cnt)
        if arclen != 0 and img_size*0.02 < area < img_size*0.9:
            approx_contour = cv2.approxPolyDP(cnt, epsilon=0.01*arclen, closed=True)
            if len(approx_contour) == 4:
                approx_contours.append(approx_contour)
    return approx_contours


def draw_contours(img, contours, file_name):
    """輪郭を画像に書き込む
    """
    draw_contours_file = cv2.drawContours(img.copy(), contours, -1, (0, 0, 255, 255), 10)
    plot_img(draw_contours_file, file_name)


def plot_img(img, file_name):
    """画像の書き出し
    """
    plt.imshow(cv2.cvtColor(img, cv2.COLOR_BGR2RGB))
    plt.title(file_name)
    plt.show()
    cv2.imwrite('./{}.png'.format(file_name), img)


def get_receipt_contours(img, K):
    """矩形検出までの一連の処理を引数の色数で行う
    """
    sub_color_img = sub_color(img, K)
    gray_img, binary_img = binarize(sub_color_img)
    contours = find_contours(binary_img)
    approx_contours = approximate_contours(img, contours)
    draw_contours(img, contours, 'draw_all_contours')
    draw_contours(img, approx_contours, 'draw_rectangle_contours')
    plot_histgram(gray_img)


# 7色に減色して矩形検出を試す
input_file = cv2.imread('/path/to/example.jpg')
get_receipt_contours(input_file, 7)

改善案2 HSV色空間にする

案の概要

ほとんどのレシートは白色なので、色を条件として背景と分離できないかと考えた(背景も白色系だと使えなくなるが)。

色を条件とした検出の場合、RGB色空間よりHSV色空間を使ったほうが検出しやすいらしい。
・RGBは、赤(R)・緑(G)・青(B)の各要素がどれだけ含まれているか(3つの色の混色の割合)で表される。同一の色でも明度や彩度の違いによってRGB3つのパラメータが変動し、範囲を指定しづらい。
・HSVは、色相(H)・彩度(S)・明度(V)のパラメータを使って表される。色相(色合い)を単独で指定できる(= 特定の色を指定しやすい)。

白色はHSV色空間だと色相は関係なくなる(0°~360°全て)ので、その長所が生かせないような気もするが、色相:制限なし / 彩度:小さめ / 明度:大きめという範囲を条件として処理してみる。

案の実践

以下の手順で処理を行う。
1. 画像をHSV色空間に変換する
2. レシートの白色とみなす範囲をHSVで指定して、それ以外はマスク(黒に変換)する
3. 2値化できた状態になるので、輪郭の検出を行う
2値化が期待した形でできているので、3枚とも検出できている。

案の評価

任意の画像だと、背景色やレシートの白色度合いによってはレシートの色(白色)とみなす指定範囲を調整する必要がある。今回の画像では偶然うまくいったが、レシートの色(白色)とみなす範囲からレシートの一部が外れた場合、その箇所は黒に分類されるため矩形検出がうまくいかなくなる。

コード

案2の一連の処理を行う際に書いたコードを以下に示す。

※ 案2に特有の部分
HSV色空間への変換後、白色部分のみにするマスク処理は cv2.inRange で行っている。
hsv_img = cv2.cvtColor(img, cv2.COLOR_BGR2HSV) # HSV色空間への変換
lower_white = np.array([0,0,100]) # 白色とみなすHSVの各値の下限
upper_white = np.array([180,25,255]) # 白色とみなすHSVの各値の上限
binary_img = cv2.inRange(hsv_img, lower_white, upper_white) # 上限と下限を指定してマスク処理する
一連の処理を行い、検出結果を画像で返すところまでのコード
import cv2
import numpy as np
from matplotlib import pyplot as plt


def binarize(img):
    """画像を2値化する
    """
    hsv_img = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
    lower_white = np.array([0,0,100])  # 白色とみなすHSVの各値の下限
    upper_white = np.array([180,25,255])  # 白色とみなすHSVの各値の上限
    binary_img = cv2.inRange(hsv_img, lower_white, upper_white)
    plot_img(binary_img, 'binary_img')
    return binary_img


def find_contours(img):
    """輪郭の一覧を得る
    """
    contours, _ = cv2.findContours(img, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)
    return contours


def approximate_contours(img, contours):
    """輪郭を条件で絞り込んで矩形のみにする
    """
    height, width, _ = img.shape
    img_size = height * width
    approx_contours = []
    for i, cnt in enumerate(contours):
        arclen = cv2.arcLength(cnt, True)
        area = cv2.contourArea(cnt)
        if arclen != 0 and img_size*0.02 < area < img_size*0.9:
            approx_contour = cv2.approxPolyDP(cnt, epsilon=0.01*arclen, closed=True)
            if len(approx_contour) == 4:
                approx_contours.append(approx_contour)
    return approx_contours


def draw_contours(img, contours, file_name):
    """輪郭を画像に書き込む
    """
    draw_contours_file = cv2.drawContours(img.copy(), contours, -1, (0, 0, 255, 255), 10)
    plot_img(draw_contours_file, file_name)


def plot_img(img, file_name):
    """画像の書き出し
    """
    plt.imshow(cv2.cvtColor(img, cv2.COLOR_BGR2RGB))
    plt.title(file_name)
    plt.show()
    cv2.imwrite('./{}.png'.format(file_name), img)


def get_receipt_contours(img):
    """矩形検出までの一連の処理を行う
    """
    binary_img = binarize(img)
    contours = find_contours(binary_img)
    approx_contours = approximate_contours(img, contours)
    draw_contours(img, contours, 'draw_all_contours')
    draw_contours(img, approx_contours, 'draw_rectangle_contours')


input_file = cv2.imread('/path/to/example.jpg')
get_receipt_contours(input_file)

OpenCVを利用した矩形検出の試行錯誤

以前作ったレシートのOCRアプリを改善するため、OpenCVを利用した矩形検出について試行錯誤を行った。

環境

Python 3.7.8
OpenCV-Python 4.5.1.48

課題設定

レシートOCRアプリにおいて、一部のレシートが正しく検出されないことがそれなりの頻度で発生している(人間の目で見る分には背景との差異がある程度あるように思える場合にも)。レシートの矩形を過不足なく検出できるようにしたい。
レシートの検出結果(赤枠が検出できたレシート)

現在の検出手順

どこに改善点があるか調べるため、現在の処理手順を順に追ってみる。
[ 手順 ]
1. 読み込んだ画像をグレースケールに変換
2. 白と黒の2値化(大津の2値化を利用)
3. 輪郭の検知
4. 検知した輪郭を条件で取捨(内の面積が一定以上ある輪郭に絞った上で、輪郭の形状を近似し、頂点が4点の輪郭のみ選択) 手順2.の2値化の段階でレシートの地が白、背景とレシート印字が黒になるイメージだったが、背景の一部も白になってしまっている。そのため、レシートと背景の境界が一部消え、手順3.で輪郭が検知されない結果となっている。

改善ポイント

2値化の段階で、レシートと背景をうまく分離できれば、その後の輪郭の検知等はうまくいくと思われる。そのため、レシートを白・背景を黒に変換する精度を上げることを目指し、2値化の前処理について下記のような複数の手法を試してみた。
改善案1 減色する
改善案2 HSV色空間にする
改善案3 エッジ検出を使う
改善案4 適応的閾値処理を使う
改善案5 ハフ変換を使う ※ レシート外形を検知できなかったので省略

先に結果

上記改善案1~4を試した結果を先に示しておく。
詳細は別記事に記載しているが、この画像を元にパラメータを調整したのでどの手法でも全てのレシートが検出できている。
どの程度汎用性があるか見るため、異なる条件を付加した2パターンの画像で試してみた。
まず、背景の明るさの変化が大きい画像で試した。
背景の明るさの変化というより、各手法の中で指定しているパラメータの範囲外になることで検出できない部分が生じている。
次に部分的に影がかかっている画像で試した。
影の影響を受けやすい案1、2において、検出できないレシートが生じている。

各改善案の詳細

各改善案の詳細・コードは下記に分割して記載した。
OpenCVを利用した矩形検出の試行錯誤_減色・色空間の変更
改善案1 減色する
改善案2 HSV色空間にする
OpenCVを利用した矩形検出の試行錯誤_エッジ検出・適応的閾値処理
改善案3 エッジ検出を使う
改善案4 適応的閾値処理を使う

まとめ

今回試した3つの画像に限ると、適応的閾値処理が一番検出率が高い結果となった。しかし、この方法も万能ではなく、設定したパラメータから外れる画像に対しては検出できない結果となる。
より良い手法や調整の仕方がありそうだが、今の知識でわかるのはここまでなので、いったんレシートOCRアプリを適応的閾値処理を利用する形で修正を行った。

大人こそ自由研究をしよう

これはGMOペパボ ディレクター Advent Calendar 2020の18日目の記事です。


自由研究が好きだ。

 

自由研究は子どもの特権ではない。むしろ、大人になってからの方が基礎知識や考える力、経済力がついている分、研究できる範囲は広がっている。

 

これは、大人になってから行う自由研究は楽しいよ、みんなやろう、そして研究結果を教えてほしい、あわよくば自分の結果も見てほしい、という趣旨のエントリーである。

自由研究の楽しいところ

やりたいことが無限に存在する中で、あえて自由研究に時間を注ぎ込むのは、一言でいえば(苦しくも)楽しいからである。
何が楽しいかというと、「自分が知りたかったこと」かつ「まだ誰も知らないこと」を自分が動いたことで最初に知ることができるのである。興奮しますね。

テーマの選び方

自由研究をするには、まずテーマを決める必要がある。
テーマは大きなものや人類全般に役立つものでなく、自分が本当に感じたことから発想したものの方がよい。

ある一定期間そのテーマを考え続けることになるので、人の役に立つことよりも自分が本当に知りたいことの方が、モチベーションが維持されやすいからである。

私の場合は、日常の中で感じたことや課題をメモしておいて、しばらく経ってもまだやりたいと思えるものの中から選定している。

テーマのアイデア出し
Trello にどんどん追加していく(後から見るとなんのことかわからないものも多い)

いかにくだらない(しかし自分には重要な)内容をテーマに据えてきたかをイメージしてもらうため、過去に取り組んだテーマとそのきっかけを記載してみる。

    • 精神や体調は、何によって決まるのか(2018)
      前職で毎日ぎりぎりの闘いをしていた中で、切実に「風邪をひきたくない」「少しでも心健やかにいたい」と感じたことがきっかけ。活動量や食事内容、睡眠時間等の様々な値を記録し、それと体調等の関係性を検証した。
    •  なるべく日陰を通るには、どのルートを選ぶべきか(2019)
      2018夏、初めて福岡で迎える夏があまりに暑く「もしやこれから毎年この暑さを乗り切らねばならぬのか」と恐れおののいたことがきっかけ。出発点と到着点を指定した時、どのルートを通るとどの程度日陰になるか計算を行った。(なお、2018夏は特異な年で、翌年以降はそれほど暑くなかった)
    • レシートの家計簿入力を少しでも楽したい(2020)
      スプレッドシートに手入力していたが、「外税か内税か軽減税率適用商品か目で見て判断するのほんとめんどい」「同じ品目名何回も入力するの人生の無駄だな」と思ったのがきっかけ。レシートをOCRした後、結果を調整してCSVに吐き出す仕組みを作った。

自由研究の進め方

取り組むテーマが決まったら、いざ研究に手をつけていく。
ここでは、上記で事例に挙げた「精神や体調は、何によって決まるのか」を例に、私なりの自由研究の進め方を示していく。

 

1.   すでに調査・実現している先達がいないか確認する
「体調に最も影響をもたらすファクターは○○!」みたいな結論を誰かが出しているなら、そのテーマに取り組む意義は薄くなる(「誰かが類似研究をしていても、〜の点を解決したいから or どうしても自分でやってみたいから このテーマを押し進める」という判断をすることもある)。
このテーマの時は、仮に先行研究があったとしても「自分という個体にカスタマイズして知りたい」状態だったため、ざっとCiNiiで検索する程度でよしとした。

 

2.   何がわかればそれを調査・実現できるか検討する
取り組む前から手順が想像できていれば容易いが、どうやって取り組めばよいかわからない状態で始めることも多い。
このテーマの時は「たぶん各種指標と体調の相関を見ればよいのでは」程度の認識だったので、「相関ってどうやって計算するのか」「本当に相関を計算することで検証できるのか」をまず調べることが必要だと判断した。

 

3.   2.で検討した内容を学ぶ

読んだだけだと即忘却し、元のわからなかった状態に戻るので、内容をメモしながら進める

このテーマの時は、統計の勉強をしないと調査を進められないと感じていたので、相関を含め統計の基本を勉強した。具体的には、統計の入門から書かれている本を10冊程度並行して読んでいった(1冊を集中して読む方法もあると思うが、複数読むことで何度も出てくるところは重要だとわかるし、1冊で理解できなかった箇所を他の本の説明で理解できるメリットがある)。

 

4.   テーマの調査・実現を試みる

たとえ実質進捗がなくても、やったことを記載して自分を励ますことが肝要である

3.で理解した内容をもとに、テーマの解決を実際に試みてみる。これで結論までいけそうならそのまま進めるが、実際は「やってみたらここが不明だからこれ以上進められない」とどこかでつまづくことが多い。そうしたら、またその不明な点がわかるように勉強、新たに得た知識を元にまた進める、を繰り返していく。
このテーマの時は、「時系列データの時は見せかけの回帰というのが発生するらしい」と知ったので、そのあたりを学べる本を探して読んだ(難しくて理解できず、かなり苦しかった記憶がある)

 

5.   ある程度区切りがよいところまで進んだらまとめる
自分の中でいったんここまで、というところまで来れたら、アウトプットする。仕事でなく遊びなので、「飽きた」「明確な結論が得られなかった」状態でもやめてよいことにしている(ただ、それであっても何らかの形でアウトプットを行う)。
子どもの自由研究は見てくれる人(教師なりクラスメイトなり)が労せず得られるが、大人になると「見てもらう」こと自体も難しい。まずは「理解してもらえる形で目に触れる場所にある」ことが最低条件となる。
一人で進めていると内容が誤っている可能性もあるのが怖いところだが、機会があれば勉強会等で発表すると助言が得られることもある。

自由研究のメリットデメリット

「楽しい」というだけでやるに値するが、一応メリットデメリットを整理する。

メリット:心の安定に寄与する
自分が動くことで、今までなかったものが生み出される。たとえ、仕事で価値をあまり生み出せていない時期も、自由研究で成果が出れば、自分の中で多少心の支えになる(業務面の課題は何も解決していないのだが、別方面で成果を得ると脳が錯覚するのである)。
また、業務後に仕事とは異なる内容に頭を使わないといけなくなり、業務のあれこれを考える余裕がなくなるので、結果的に気持ちの切り替えもしやすい。
※ なお、自由研究でも進捗が出なくて二重に苦しくなることもままある

デメリット:可処分時間の減少
自由研究に取り組むと、思った以上に時間を食うことに気づく。しかし、可処分時間の大半を注がないとなかなか進捗が出ないので、楽しくならないというジレンマがある。遊ぶ時間や他の勉強をする時間に影響するので地味につらい。

結論

大人になってから行う自由研究は(苦しくも)楽しいぞ、みんなやろう

 

よい自由研究ライフを!

レシートを読み取ってCSVに変換するデスクトップアプリを作った

こんな感じで、レシートを検知して切取り → 1枚ずつOCR → 誤って読み取ったところの修正・付加情報の追加を手作業で受け付け → CSVで保存 …という流れをGUI上で行えるようにした。

 

きっかけ

元々手作業でレシートの入力を行っていたが、少しでも手間をかけずに行いたいと思ったため。

要件

全て電子決済にしてその電子的な履歴を参照するようにすれば、こんな面倒なことをする必要はないと思う。今回、以下の要件を実現したかったため、このようなちょっと操作が面倒なものになった。

    • レシートの合計額でなく、品目単位で見れるようにしたい
      1回の購入の中でも品目の分類が異なる(スーパーで食品と雑貨を同時に買うとか)ことがあり、分けてデータを集計できるようにしたいため、品目ごとの取得が必要だった。
    • 軽減税率や外税/内税等を反映させて品目ごとの価格を自動で計算したい
      レシートを観察すると、表記の仕方として外税の場合と内税の場合があり、さらに品目により軽減税率の場合もある。なるべくそれらの条件は自動で読み取って、計算を自動で行うようにしたかった。
    • レシートを複数枚同時に扱えるようにしたい
      OCRするには当然レシートの画像が必要だけど、1枚ごとに撮影するのは面倒なので、複数枚の画像でも扱えるようにしたかった。
    • そのものの品目名で印字されていないが繰り返し出てくる品目は、2回目からは自動で入力してほしい
      例)「スッキリCAテツ1L」というのは「牛乳」という名称で登録したい(「すっきりCa鉄 1000ml」という名称で販売されている、カルシウムや鉄分が付加されている牛乳である)。
    • 分類もなるべく自動で判定してほしい
      「牛乳」が分類としては「食費」というのは(自分の分類方法では)自明であり、こちらも2度目からは自動入力されるようにしたかった。
    • ローカルで操作を完結させたい
      レシート画像を通信するのは何となく不安だったので、全ての操作がローカル上で完結するようにしたかった。

処理の流れ

大まかには以下の流れで処理するようにした。

 

1 OpenCVでレシートの矩形を検知して切取り
   ↓
2 TesseractでOCRを行う
   ↓
3 正規表現で頑張って品目や価格、購入日等を抽出
   ↓
4 品目名等を過去の履歴を参照して修正したり、品目ごとの価格(税込・軽減税率適用)を計算したりする
   ↓
5 Tkinter(GUI作成用のPythonの標準ライブラリ)でデータ(品目や価格等)を表示、手作業での修正を受け付ける
   ↓
6 最終的なデータを取得してCSVに吐き出し

今後

とりあえず最低限動くというレベルなので、今後自分で実際に使っていってみて、不便なところを徐々に直していくようにしたい。
特に以下の点についてはすでに気になっている。

 

    • 品目ごとの価格の調整
      外税表記で、合計額に対し消費税を最後に計算されているレシートだと、品目ごとに消費税を計算すると合計額が実際の価格から数円ずれてしまうことがある。今は単純に小数点以下四捨五入にしているけど、何らかの調整を入れて合計額がぴったり合うようにしたい。
    • レシートの矩形の検知性能向上
      レシートと背景のコントラストが低い画像の場合など、うまくレシートを検知できない場合がある。OpenCVや画像認識を勉強して、もう少し性能を上げたい。
    • 文字の認識精度向上
      レシートは半角カタカナもけっこうな頻度で使われていて、特にその場合の認識精度は相当低い。ただ、OCRはtesseractを使わせてもらっているだけなので、今のところ具体的な改善点を思いついていない。
正直、すでに「レシートをまとめて撮影するのさえ面倒だな…」という気持ちが芽生えており、このファーストステップの簡略化の方法も検討が必要かもしれない。

Couseraの機械学習講座を受講した

オンライン教育サービスであるCouseraで、機械学習の基礎的な学習講座として有名なMachine Learning講座を受講した。

前々から気になりつつ、「自分がついていけるレベルだろうか」「英語わかんないしな」(講義には日本語訳がついているけどテストや課題は英語)、「Pythonじゃないし」(プログラミング課題はOctaveで提出する)となかなか踏み出せずにいた。
在宅勤務になり家にいる時間が増えたことも後押しになって、受講することができたので、感想等記録しておく。

扱われる内容

下記のキーワードに関連する内容について、11週にわたり講義が行われる。

Week 1 機械学習の概要
線形単回帰、最小二乗法、最急降下法

Week 2 線形重回帰
特徴量のスケーリング、正則化

Week 3 分類 ロジスティック回帰
過学習、正則化、One-vs-All

Week 4・5 ニューラルネットワーク
隠れ層、論理ゲート、backpropagation、gradient checking、ランダム初期化

Week 6 機械学習の評価
交差検証、high bias、high variance、学習曲線、適合率、再現率、F値

Week 7 サポートベクタマシン(SVM)
マージン、決定境界、カーネル法

Week 8 クラスタリング・主成分分析(PCA)
K平均法、局所最適、エルボー法
次元削減、射影誤差、共分散行列

Week 9 異常検知・レコメンドシステム
正規分布、多変量正規分布
協調フィルタリング、類似度

Week 10 大規模データ
確率的勾配降下法、ミニバッチ勾配降下法、逐次学習、並列化

Week 11 Photo OCR
パイプライン、スライディングウインドウ、データ合成、ceiling analysis

学習の進め方

ノート
こんな感じでノートにメモしていった

各週、動画による講義 + テスト + プログラミング課題から構成されている。
私は講義を平日にざっと見て(わからないところがあってもあまり気にせず最後までいったん見る)、休日にもう1回見直しながら話の流れをノートにメモしていった。平日見たときは「何言ってるか全然わからん」と思った箇所も、2回目に休日に見た時は「あれ、なんで理解できなかったんだろう」となることも多かった(何回見直してもやっぱりわからん、となる箇所ももちろんあった)。
講師のAndrew先生の説明がわかりやすかったのはもちろんだけど、ノートにまとめようとする過程で理解が曖昧な箇所をつぶし、流れをしっかり捉えられるのが自分にはよかったのかなと思う。

かかった時間

ネット上の体験談だと1ヶ月未満で完了したという方も割と見かけたけど(みんな天才なのかな?と思った)、私は設定されているのと同じペースで進めていったので、約2ヶ月かかった。週により内容の重さの差が大きくて、3時間で終わった週もあれば15時間以上かかった週もあった。

感想

講座全体を通じて感じたことを記録しておく。

  • 勉強を続けて納得感を高めていきたいと思った

とても勉強になったので受講してよかったなと思う。
説明が非常にわかりやすいので、そのアルゴリズムの意図や、数式の意味するところの大枠について理解することができた。全体的に納得しながら進めることができたのだけど、「この数式がなぜこうなるかは、ここでは説明しない」という説明で次に進む部分もしばしばあったので、数式の導出などは少しもやもやが残った。また、課題は一から自分でコードを書くのではなく、重要な数式部分のみ自分で書くという形式だった。そのため、ふんわりとした理解になっている自覚がある。自分でPythonで書き直してみるとか、数式の導出をしてみるとかするべきなのだろう。勉強を続けていって、理解を深めていきたいと思う。

  • 英語をもうちょっと読めるようになると色々楽だなと思った

講義には有志の方がつけてくれた訳文がついているけど、テストや課題は英語で書かれている。最初の数週は頑張って単語を調べながら読んでいたけど、途中から面倒になってDeepLに突っ込んで訳文を読むようになった。俄然課題の進みが速くなって、自分の英語力の低さを改めて実感した。DeepLは訳が自然で、訳文だと意味が不明で結局原文を読むみたいなことはほとんど発生しなかった(訳文を読んで「解くのに必要な前提条件が足りない…」となって、原文見たら訳されていない箇所があった、ということは数度あった)。

  • ペース配分を自分でしなくて済むのって楽だなと思った

課題を提出すると、何割終わったか示してくれる

オンライン講座では当たり前なのかもしれないけど、見終わった動画にはチェックマークがついたり、プログラミング課題も途中提出の度にどこまで終わったか明確に表してくれる。「今週中にあと3個動画見て課題を解くから、x時間くらいで終わる」とわかると、残りの時間は別の学習に充てようなど見通しが立てやすい。ペース配分を自分でしなくて済むこと、どこまで進んでいるか示してくれることがこんなに楽なのかというのは意外な発見だった。

今後機械学習の勉強をしていく上で、非常に役に立つ講座だった。継続して学習していきたい。