レシートをスキャンして日付ごとに分類してみた

ドキュメントスキャナのDR-C125を買ったので、面白い使い道ないかな〜と考えた結果、レシートをスキャンして日付ごとに分類させて見ることにしました。
スキャンさせるだけなら、ものぐさで家計簿つけれない自分でも続くはず。。。
別に合計金額とかも出すわけじゃないので、家計簿用途にはまったく使えないけど、時々昔買ったあの製品いくらだっけ?と思い出せず悩むことがあるので、そういう時には有効かと

ステップ1:スキャン時の設定

適切な設定を探す作業は、分類のためのプログラム書いている時間の数倍かかった。。。
結論としては、

モード
アドバンストテキストエンハンスメントII
明るさ
かなり暗く(50くらい?)
重送検知
超音波検知のみ(長さ検知をしてしまうと、レシートは細長いので誤判定される)
読み取り面
片面(両面にすると、裏に店名のロゴとかがあるレシートの時問題あり)
カラードロップアウト
クレジットカードのレシートとかは紙が青だったり、赤だったりするので読み込む際に適宜設定

な感じ。

ステップ2:プログラム作成

正規表現でパターンマッチングするだけだと思いきや、スキャナの誤検知の尻拭いをしたり、レシートのフォーマットが微妙に違ったりでちょっぴり苦労。

ポイントは

  • 「0」を「o」、「1」を「l」と誤検知することが多い
  • 年は「2011」と「11」の2種類がある
  • 年が省略されて月日のみの場合もある
  • 年月日に使用されるセパレータは3パターン
    • 2011年8月7日
    • 2011/8/7
    • 2011-8-7
  • 月や日が一桁のときは前に0が入ったりは入っていなかったりする
  • ポイントの失効日や広告が日付として入っている場合がある

日付に「2011-8-7」のパターンを認めてしまうと誤検地が一気に上昇する、店の住所が「XX町11-5-6」とかだと完全に区別がつかない・・・
あと何気に「2011/8/7」のパターンもスラッシュが文の中に意外とあるので、変なところが引っかかってしまう。
という訳で信頼度は

  1. 2011年8月7日
  2. 2011/8/7
  3. 2011-8-7

の順となり、この順序で調べていくことになる。

ポイントの失効日や広告の日付は大抵未来の日付なので、プログラムで解析した日付よりも未来の日付を削ってやれば上手くいく。
複数の日付が取れた場合はその中の一番過去の日付を採用してやればいいという考え方もあるが、今回試した限りでは古いほうの日付は誤検知日付であったという例が結構あったので、その対応は行わず。

というわけでプログラム。
今回は、日付の抽出のみで実際にディレクトリごとに仕分けとかは行っていない。

# -*- coding: utf-8 -*-
require 'kconv'
require 'date'

NUM_REG = '[0-9ol]'

def to_num(str)
  str.to_s.tr("ol", "01").to_i
end

def split_date(txt, ym_sep, md_sep)
  ret = []

  txt.scan(/(?:((?:20)?#{NUM_REG}{2})[#{ym_sep}])?(#{NUM_REG}{1,2})[#{md_sep}](#{NUM_REG}{1,2})(?:)?[^\-]/u) do |y, m, d|
    y = 2011 if y==nil
    y = to_num(y)
    y += 2000 if y<100
    m = to_num(m)
    d = to_num(d)

    if Date::exist?(y, m, d) && Date::new(y, m, d) <= Date::today
        ret << "#{y}/#{m}/#{d}"
    end
  end

  ret
end

def read_date(filename)
  ret = []
  date = ""
  IO.popen("xdoc2txt -o=0 -o=1  #{filename}", 'r') do |io|
    data = io.read.toutf8.gsub(/\s/, "")
#    puts data
    ret += split_date(data, '', '')
    ret += split_date(data, '/ノ', '/ノ') if ret==[]
    ret += split_date(data, '\-', '\-')   if ret==[]
  end
  ret.uniq
end

def analyze_date()
  empty_day    = 0
  single_day   = 0
  multiple_day = 0
  Dir.glob('*.pdf').each do |file|
    date_list = read_date(file)

    case date_list.size
    when 0
      empty_day += 1
    when 1
      single_day += 1
    else
      multiple_day += 1
    end

    date_list = date_list.join(" ")
    puts "#{file}:#{date_list}"
  end
  puts "empty_day:#{empty_day} single_day:#{single_day} multiple_day:#{multiple_day}"
end

analyze_date()

ステップ3:解析精度計測

作ったプログラムがどの程度正しく動くか試してみた。
使用データはこの日のためにコツコツと貯めていた124枚のレシートたち。

実行結果
$ ruby pick_out_date.rb
レシート20110807132143.pdf:2011/7/8
レシート20110807132145.pdf:
レシート20110807132148.pdf:2011/7/21
レシート20110807132150.pdf:2011/7/18
レシート20110807132152.pdf:2011/7/18
レシート20110807132155.pdf:2011/7/22
レシート20110807132159.pdf:2011/7/23

(中略)

レシート20110807232317.pdf:2011/1/1 2011/7/31
レシート20110807232321.pdf:2011/1/1 2011/7/31
レシート20110807232322.pdf:2011/7/31
レシート20110807232324.pdf:2011/7/30
レシート20110807232328.pdf:2011/7/30 2011/8/8
レシート20110807232330.pdf:2011/8/7
レシート20110807232332.pdf:2011/7/30
レシート20110807232335.pdf:
レシート20110807233227.pdf:2011/7/31
レシート20110807233229.pdf:2011/7/31
レシート20110807233402.pdf:2011/7/31
レシート20110807233403.pdf:
レシート20110807233611.pdf:2011/7/31
empty_day:28 single_day:89 multiple_day:7
まとめ
日付抽出失敗 23%
日付抽出成功 72%
日付複数抽出 6%

割といい感じで抽出できている。
ただし、日付抽出に成功したもののうち何%が本当に正しい日付かどうかは確認していない。
本当に正しいかどうかを確認すると50%程度になってしまう気がしている。というのもデータを見渡してみると絶対違うだろと突っ込みたくなるのが少なからずあるため。。。

結論

日付による自動分類は出来なくはないが、若干微妙・・・
もう少し頑張れば行けるかも。