スイマーによるスプリント系スポーツのための日曜プログラミングシリーズ第4弾。そういえばタイムの成型の話以降は別にスプリントに限った話でもなかった。
もはや第3弾を超え、平日の定時後もつぎ込まれる大作となりました(在宅勤務で行後の時間がたっぷりとれたのも一因)が「おしごとではない」という意味で「日曜プログラミング」の名前は残しておくことにします。
前置き
さて、前回記事の「超速報」は仕組みとしては想定通りに動き、掲示を見に行かなくても結果が見られる・閉会後も他の人の記録含めてスマホから見返せるということでそこそこ評判のようでした。
ところがどっこい、このときは自分がシステムの一部となってしまいPCから離れられず。もともと出場予定なかったからよかったけど、次の大会は自分も出るのである程度省力化しておかないとビショビショのままタイム入力に駆け戻ることになりかねない(プールサイドを走ってはいけません)。
これはSEとしてはあるまじき失態ぞ……ということでGoogleのCloud Vision APIを使ってOCR機能を作ることにしました。
作ったもの・使った技術
大まかな構成
- 入力用WEBページ(カメラ読み取り、OCRリクエスト、OCR結果取得と加工、加工済みデータの登録)
- Google Spread Sheet + Google Apps Script(加工済みデータのストレージ、外部公開用API)
- 公開用WEBページ(Vue.jsでGASから取得したデータを公開)
このうちGASの外部公開用APIと公開用WEBページは前回記事で作成済みのもの。
カメラ台の作成
どしょっぱなは工作です。図工好きよ。
OCRに回すことを考えると可能な限り歪みのない画像を取得する必要があるので、PCのモニタ上にについているようなカメラでは撮影が難しい。
自炊クラスタのようにカメラを固定して原稿を差し入れる構造にしたかったので、スマホを外付けカメラモジュールとして使用できるIriun Webcamを使用し、さらに撮影用の台を用意することにしました。
Seriaのワイヤー整理棚(大きいほう)を買ってきてスマホを乗せると、天板のワイヤーの隙間からいい感じの距離感で棚の下のレシートを撮影できます(レシート全体が映らないといけないので、距離は結構大事)。
さらにレシートはロールから出てきたばかりで基本的に丸まっているので、硬質カードケース(B4)の1辺を切り出して「レシート平らにするやーつー」を作成。これにレシートを入れたうえで棚の下に差し入れることで、きれいな矩形のレシート画像が撮れる。整理棚とカードケースで合計200円。やっす。
あと、光源との位置関係によってはレシートに影が落ちてしまって精度が下がるので、棚の周囲にカーテン的なものを張ったほうがいいです。私は今回その辺にあったコピー用紙で棚を囲みました。
カメラ映像の取り込みと撮影
HTMLで既存の画像以外のものを扱うのは初めてだったので、カメラにアクセスするところから先人のお知恵を拝借します。
参照:[HTML5] カメラをJSで操作し写真を撮影する
画像を回転
スマホカメラの画像が横長の映像として取得されるので、レシート(縦長)の全体をおさめかつOCRできる向きにするため、画像を回転します。
参照1:【Javascript】video/canvasを上下180度反転(回転)させる方法 【お家IT#10】
参照2:rotate(angle) - Canvasリファレンス
(この回転があるせいでブラウザ上の要素の配置が非常に面倒になったんですがそれはそれとして)
フィルタの適用
せっかくなのでOCR制度を向上するため、フィルタを適用してみました。
参照:Canvasを用いた9つの画像処理フィルターとそのアルゴリズムの解説|Black Everyday Company
画像を送る準備
Cloud Vision APIに画像を送るためには、いったんbase64文字列に変換する必要があるということでこちらも拝借。
参照:HTML5 の canvas 要素を base64 文字列化し画像として保存する方法まとめ
OCR結果の取得
レシートは通常の文章と違って文字間が開いているので、OCRが返してくる結果のblockやparagraphはあまり使い物になりません。そこで結果の1文字ずつの位置情報を使って行を分割、行内の順番を整理して「行ごと」のデータを作成します。
さらに行内の表示内容はルール化されているため、文字数ベースで行内のセルを分割していきます。ついでによくある読み取りミスも修正してしまう。レシートのフォーマットは前回記事参照。
//得られた結果を画面に表示する
function showResult(result){
//解析結果をオブジェクトに格納
doc = [];
bounds=[];
doc = result.responses[0].fullTextAnnotation;
for(var page of doc.pages){
for(var block of page.blocks){
for(var paragraph of block.paragraphs){
for(var word of paragraph.words){
for(var symbol of word.symbols){
var x = symbol.boundingBox.vertices[0].x;
var x2 = symbol.boundingBox.vertices[1].x;
var y = symbol.boundingBox.vertices[3].y;//小数点の所属行を正確に判定するため、Y位置は左下を取得する
var t = symbol.text;
bounds[bounds.length]={"x":x, "x2": x2, "y":y, "t":t};
}
}
}
}
}
sort();
}
function sort(){
bounds.sort(function(a, b) {
if (a.y < b.y) {
return -1;
} else {
return 1;
}
});
var oldY = -1;
var line = [];
var lines = [];
var str = "";
var strings = "";
var threshold = Number(document.getElementById("Ythreshold").value);//改行検出閾値
console.log("Y-threshold:" + threshold );
var singleLine = "";
for(var bound of bounds){//各行内ソート処理
if(oldY == -1 || (oldY - threshold <= bound.y && bound.y <= oldY + threshold)){
//一番最初の文字もしくは同一行内
oldY = bound.y;
line[line.length]=bound;
}else{//行切り替え時処理(前行のプッシュ)
//line内ソート
line.sort(function(a, b) {
return (a.x < b.x)? -1:1
});
//行内文字全結合
for(var s of line){
singleLine = singleLine + s.t;
}
console.log(singleLine);
//行種類判定
if(singleLine.substr(1,1)=="P" || singleLine.substr(1,1)=="p"){
//ラップ見出し(P L TURN TIME)
}else if(singleLine.search("M")!=-1 || singleLine.search("LAP")!=-1 || singleLine.search("GOA")!=-1 || singleLine.search("OAL")!=-1){
//ラップ見出し(xxM LAP)
}else{
//タイム行
lines[lines.length]=[found1(singleLine.substr(0,1)), found1(singleLine.substr(1,1)), found1(singleLine.substr(2,1)), timeFIX(singleLine.substr(4))];//line;
str = lines[lines.length-1].join(' ');
if(strings.length==0){
strings = str;
}else{
strings = strings + '\n' + str;
} //strとstringsはlog参照用
str = "";
}
line = [];
oldY = bound.y;
singleLine = bound.t;
singleLine = "";
line[line.length]=bound; //元データ保持用
}
}
//最終行処理
//line内ソート
line.sort(function(a, b) {
return (a.x < b.x)? -1:1
});
//行内文字全結合
for(var s of line){
singleLine = singleLine + s.t;
}
console.log(singleLine);
//行種類判定
if(singleLine.substr(1,1)=="P" || singleLine.substr(1,1)=="p"){
//ラップ見出し(P L TURN TIME)
}else if(singleLine.search("M")!=-1 || singleLine.search("LAP")!=-1 || singleLine.search("GOA")!=-1 || singleLine.search("OAL")!=-1){
//ラップ見出し(xxM LAP)
}else{
//タイム行
//linesに追加
lines[lines.length]=[found1(singleLine.substr(0,1)), found1(singleLine.substr(1,1)), found1(singleLine.substr(2,1)), timeFIX(singleLine.substr(4))];//line;
str = lines[lines.length-1].join(' ');
if(strings.length==0){
strings = str;
}else{
strings = strings + '\n' + str;
} //strとstringsはlog参照用
str = "";
}
console.log(lines);
viewTable(lines);
}
参照:【Google Colab】Vision APIで『レシートOCR』
OCR結果の修正
一発で100%取得できるとも限らないし、ソフトタッチ等々でレシートにタイムが出てこないこともあるので、OCR後の編集もブラウザ上でできるようにしたい……とおもってGoogle先生にお伺いを立てたらコレだよ。
参照:contenteditable - HTML: HyperText Markup Language | MDN
どうかんがえても使わざるを得ない。惜しいのはタブでセルの横移動ができないことですね。javascriptゴリゴリ書いたらできるのかな。
データの登録
編集完了したらヘッダ項目(泳法・距離・組)を追加して、以前作成したGoogleスプレッドシートの結果速報シートに転記する。
var classname = $('#racedata [name=classname]:checked').val();
var distance = $('#racedata [name=distance]:checked').val();
var grpnumber = $('#racedata [name=grpnumber]').val();
var postDat = [];
var rowDat = {};
var tbl = document.getElementById('editTable');
for(var row of tbl.rows){
if(row.cells[0].firstChild.innerText != ''){
rowDat = {"distance":distance,
"class":classname,
"group":grpnumber,
"lane":row.cells[1].firstChild.data,
"turn":row.cells[2].firstChild.data,
"time":row.cells[3].firstChild.data
};
postDat[postDat.length] = rowDat;
}
}
var url = "https://script.google.com/macros/s/APIキーがここに入る/exec";
var postParam =
{
"method" : "POST",
"mode" : "no-cors",
"Content-Type" : "application/x-www-form-urlencoded",
"body" :JSON.stringify(postDat)
};
fetch(url, postParam);
$('#editTable').empty();
video.play();
video.style.display="block";
canvas.style.display="none";
なお、POSTでGASを経由してスプレッドシートにJSONを投げ込もうとしたらCORS (Cross-Origin Resource Sharing) に躓いたので、こちらの記事を参考に投げ方を修正。POSTは無理っすわーっていう記事が多かった中で大変助かりました。
参照:[備忘録] GASのdoPost()にJavaScriptからJSONを渡す方法 - Qiita
完成!
インクリボン交換直後とか、笑っちゃうくらい読み取り精度が高くてほぼ修正不要でした。何なら人間が見間違える0と8を読み分けてきたりする。一方でインクが薄れてくるとどうしても文字が読み取りにくくなるので人間の視覚って高度だなと思った次第。
入力の手間が省けたので無事自分のレースにも参加できて、もともと担当じゃなかったはずの当日のエントリーを組分けする作業をしていたら結局2レース目は棄権してしまった(あれ???)(運営人員少なすぎではないか???)
ということで次回はエントリーの組分けを楽にする仕組みを作るぞ(涙)