写真に撮った領収書は、まだ文字ではない
領収書をスマホで撮ると、画像ができます。人には「合計 2,480円」と読めても、コンピューターにはまだ画素の表です。
文字を読み取ると、文字と場所の組が並んだ結果が返ります。このレッスンでは、それを読む順に直し、項目と値を取り出します。
このレッスンのゴール
メモ
読み取った文字の箱を読む順に並べ、項目とその値を取り出せます。
キーワード
| ことば | いみ |
|---|---|
| 箱 | 一つの文字のまとまりと、その場所 |
| 読む順 | 上から下、同じ高さでは左から右に並べること |
| 許容幅 | 同じ行とみなす、高さの差の大きさ |
| 項目 | 名前と値の組。名前は「合計」のようなラベル |
| 確信度 | 読み取りがどれだけ確かかを表す数 |
学生が入力するセルは、このプレビューでは空白です。
ページは箱のリストである
文字の読み取りは、ページを箱のリストにします。箱は、文字、左上の角の位置、確信度を持ちます。
箱は、読み取った順に返るとは限りません。見た目の順に並べ直さないと、「合計」と金額が離れてしまいます。
ページから出た四つの箱
| 文字 | 横 x | 縦 y | 確信度 |
|---|---|---|---|
| 店名: | 10 | 10 | 99 |
| ひまわり書店 | 80 | 12 | 97 |
| 合計: | 10 | 70 | 99 |
| 2,480円 | 80 | 69 | 61 |
学生が入力するセルは、このプレビューでは空白です。
読む順には許容幅がいる
同じ行の箱でも、縦の位置は少しずれます。「合計:」は 70、金額は 69 です。縦だけで並べると、金額が先に来てしまいます。
そこで、最初の箱の高さから許容幅以内の箱を、同じ行に入れます。行の中は、左から右に並べます。
よくある間違い
注意
「高さが同じ数のものだけが、同じ行だ」と考えがちです。紙を斜めに撮ると、同じ行でも高さは少しずれます。許容幅を 0 にすると行が割れ、項目の値が見つからなくなります。
項目は、ラベルとその値
領収書では、「合計:」のようなラベルのすぐ右に、値が書かれています。ラベルの次の箱が、その項目の値です。
値がない項目は、空として残します。確信度の低い値は、人が確かめる目印にします。
モデル用の補助を書く(1)
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'const base = process.env.AI_URL || \"http://ai:4000\";' '// 応答に含まれるトークン数。なければ null。' 'function count(data, name) {' ' return data.usage && typeof data.usage[name] === \"number\" ? data.usage[name] : null;' '}' > model.js"ここでは表示のみで、実行されません。無料アカウントを作成すると、実行ボタンで自分のラボのターミナルに入力できます。
モデル用の補助を書く(2)
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'async function ask(messages, sample, max) {' ' try {' ' const res = await fetch(base + \"/v1/chat/completions\", {' ' method: \"POST\",' ' headers: { \"content-type\": \"application/json\", authorization: \"Bearer lab\" },' ' body: JSON.stringify({ model: \"lab\", messages: messages, max_tokens: max || 100 }),' ' signal: AbortSignal.timeout(30000),' ' });' ' if (res.ok === false) throw new Error(\"status \" + res.status);' ' const d = await res.json();' ' return { text: d.choices[0].message.content, inTokens: count(d, \"prompt_tokens\"), outTokens: count(d, \"completion_tokens\"), recorded: false };' ' } catch (error) {' ' console.log(\"記録済みサンプル: モデルが答えませんでした (\" + error.message + \")。\");' ' return { text: sample, inTokens: null, outTokens: null, recorded: true };' ' }' '}' 'module.exports = { ask };' >> model.js"ここでは表示のみで、実行されません。無料アカウントを作成すると、実行ボタンで自分のラボのターミナルに入力できます。
ページを読むスクリプトを書く(1)
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'const { ask } = require(\"./model\");' 'const boxes = [' ' { text: \"2,480円\", x: 80, y: 69, conf: 61 },' ' { text: \"店名:\", x: 10, y: 10, conf: 99 },' ' { text: \"11月3日\", x: 80, y: 43, conf: 88 },' ' { text: \"ひまわり書店\", x: 80, y: 12, conf: 97 },' ' { text: \"合計:\", x: 10, y: 70, conf: 99 },' ' { text: \"日付:\", x: 10, y: 40, conf: 98 },' '];' > page3.js"ここでは表示のみで、実行されません。無料アカウントを作成すると、実行ボタンで自分のラボのターミナルに入力できます。
ページを読むスクリプトを書く(2)
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'function lines(list, tol) {' ' const sorted = list.slice().sort(function (a, b) { return a.y - b.y || a.x - b.x; });' ' const out = [];' ' sorted.forEach(function (b) {' ' const last = out[out.length - 1];' ' if (last && Math.abs(b.y - last[0].y) <= tol) last.push(b);' ' else out.push([b]);' ' });' ' return out.map(function (l) { return l.sort(function (a, b) { return a.x - b.x; }); });' '}' >> page3.js"ここでは表示のみで、実行されません。無料アカウントを作成すると、実行ボタンで自分のラボのターミナルに入力できます。
ページを読むスクリプトを書く(3)
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'function fields(ls) {' ' const found = {};' ' ls.forEach(function (line) {' ' line.forEach(function (b, i) {' ' if (b.text.endsWith(\":\") && line[i + 1]) found[b.text.slice(0, -1)] = line[i + 1];' ' });' ' });' ' return found;' '}' >> page3.js"ここでは表示のみで、実行されません。無料アカウントを作成すると、実行ボタンで自分のラボのターミナルに入力できます。
ページを読むスクリプトを書く(4)
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'async function main() {' ' const ls = lines(boxes, 5);' ' ls.forEach(function (l) { console.log(l.map(function (b) { return b.text; }).join(\" | \")); });' ' const f = fields(ls);' ' Object.keys(f).forEach(function (k) {' ' const low = f[k].conf < 70 ? \" 要確認\" : \"\";' ' console.log(k + \" = \" + f[k].text + \" (確信度 \" + f[k].conf + \")\" + low);' ' });' ' const q = \"文字の読み取りで確信度が低い項目を、人に確かめてもらう理由を、短い一文で書いてください。\";' ' const r = await ask([{ role: \"user\", content: q }], \"確信度が低い項目は読み間違いの可能性が高く、金額のような大事な値は人が確かめるべきだからです。\", 60);' ' console.log(\"返事: \" + r.text);' '}' 'main();' >> page3.js"ここでは表示のみで、実行されません。無料アカウントを作成すると、実行ボタンで自分のラボのターミナルに入力できます。
ページを読み、項目を取り出す
docker exec -w /home/node/project agent node page3.jsここでは表示のみで、実行されません。無料アカウントを作成すると、実行ボタンで自分のラボのターミナルに入力できます。
出力の例
| 行 | 示していること |
|---|---|
| 店名: | ひまわり書店 | 同じ行の箱が、左から右に並んだ |
| 合計: | 2,480円 | 金額の高さが 1 だけ違っても、同じ行に入った |
| 合計 = 2,480円 (確信度 61) 要確認 | 確信度が 70 に満たないので、人が確かめる |
| 返事: ... | モデルの一文。先に「記録済みサンプル」の行が出ることもある |
学生が入力するセルは、このプレビューでは空白です。