この記事は JavaScript を用いた QRコードのデコードに関する内容です。
Qiita の記事でも、いくつかこの話題に関する記事がありますが、「シンプルな最低限の実装となるとどうなるか」という話を調べて試してみたくなり、今回の内容を試しました。
ライブラリ(jsQR)を試す
Web上の記事を検索してみていると、JavaScript用のライブラリはいくつかあるようですが、よく見かけたものの 1つに jsQR というものがありました。
cozmo/jsQR: A pure javascript QR code reading library. This library takes in raw images and will locate, extract and parse any QR code found within.
https://github.com/cozmo/jsQR
例えば Qiita の記事でいうと、このあたりの記事などです。
公式デモサイトを使ったお試し
上記のサイトを見るとデモページがあったので、それを開いて PC やスマホで動かしてみました。
●jsQR Demo
https://cozmo.github.io/jsQR/
具体的には、PC上で以下のサイトを利用して、適当な文字列を埋め込んだ QRコードを生成し、それを上記のページを開いたスマホのブラウザで読み込ませてみました。
●QRコード(二次元バーコード)作成【無料】
https://cman.jp/QRcode/
QRコードに埋め込む内容で日本語を入力していた場合、デモサイトでその文字列が表示されたなかったのですが、その話はいったん置いておいて、アルファベット・半角スペースのみで作った文字列で再度試したました。こちらは問題なく成功。
(原因については、「マルチバイトを考慮した処理がされてない?」とか「ライブラリ内でマルチバイトを扱えない?」とか、そのあたりかなとか思いつつ...)
【追記】 これは、QR生成を行うサイト側の文字コードの問題でした...(詳細は後で補足)
QRコード処理に関するJavaScriptのライブラリのデモサイト、日本語の文字だとデコード結果が表示されない?
— you (@youtoy) August 1, 2021
(マルチバイトを考慮してない実装なだけかな?)
アルファベットと半角スペースで作った文字列は、大丈夫だった。 pic.twitter.com/I0KogL4oi3
ちなみに、デモサイトのソースコードを表示させてみると、以下のような内容でした。
<html>
<head>
<meta charset="utf-8">
<title>jsQR Demo</title>
<script src="./jsQR.js"></script>
<link href="https://fonts.googleapis.com/css?family=Ropa+Sans" rel="stylesheet">
<style>
body {
font-family: 'Ropa Sans', sans-serif;
color: #333;
max-width: 640px;
margin: 0 auto;
position: relative;
}
#githubLink {
position: absolute;
right: 0;
top: 12px;
color: #2D99FF;
}
h1 {
margin: 10px 0;
font-size: 40px;
}
#loadingMessage {
text-align: center;
padding: 40px;
background-color: #eee;
}
#canvas {
width: 100%;
}
#output {
margin-top: 20px;
background: #eee;
padding: 10px;
padding-bottom: 0;
}
#output div {
padding-bottom: 10px;
word-wrap: break-word;
}
#noQRFound {
text-align: center;
}
</style>
</head>
<body>
<h1>jsQR Demo</h1>
<a id="githubLink" href="https://github.com/cozmo/jsQR">View documentation on Github</a>
<p>Pure JavaScript QR code decoding library.</p>
<div id="loadingMessage">🎥 Unable to access video stream (please make sure you have a webcam enabled)</div>
<canvas id="canvas" hidden></canvas>
<div id="output" hidden>
<div id="outputMessage">No QR code detected.</div>
<div hidden><b>Data:</b> <span id="outputData"></span></div>
</div>
<script>
var video = document.createElement("video");
var canvasElement = document.getElementById("canvas");
var canvas = canvasElement.getContext("2d");
var loadingMessage = document.getElementById("loadingMessage");
var outputContainer = document.getElementById("output");
var outputMessage = document.getElementById("outputMessage");
var outputData = document.getElementById("outputData");
function drawLine(begin, end, color) {
canvas.beginPath();
canvas.moveTo(begin.x, begin.y);
canvas.lineTo(end.x, end.y);
canvas.lineWidth = 4;
canvas.strokeStyle = color;
canvas.stroke();
}
// Use facingMode: environment to attemt to get the front camera on phones
navigator.mediaDevices.getUserMedia({ video: { facingMode: "environment" } }).then(function(stream) {
video.srcObject = stream;
video.setAttribute("playsinline", true); // required to tell iOS safari we don't want fullscreen
video.play();
requestAnimationFrame(tick);
});
function tick() {
loadingMessage.innerText = "⌛ Loading video..."
if (video.readyState === video.HAVE_ENOUGH_DATA) {
loadingMessage.hidden = true;
canvasElement.hidden = false;
outputContainer.hidden = false;
canvasElement.height = video.videoHeight;
canvasElement.width = video.videoWidth;
canvas.drawImage(video, 0, 0, canvasElement.width, canvasElement.height);
var imageData = canvas.getImageData(0, 0, canvasElement.width, canvasElement.height);
var code = jsQR(imageData.data, imageData.width, imageData.height, {
inversionAttempts: "dontInvert",
});
if (code) {
drawLine(code.location.topLeftCorner, code.location.topRightCorner, "#FF3B58");
drawLine(code.location.topRightCorner, code.location.bottomRightCorner, "#FF3B58");
drawLine(code.location.bottomRightCorner, code.location.bottomLeftCorner, "#FF3B58");
drawLine(code.location.bottomLeftCorner, code.location.topLeftCorner, "#FF3B58");
outputMessage.hidden = true;
outputData.parentElement.hidden = false;
outputData.innerText = code.data;
} else {
outputMessage.hidden = false;
outputData.parentElement.hidden = true;
}
}
requestAnimationFrame(tick);
}
</script>
</body>
</html>
上記の内容を HTMLファイルにして、jsQR.js を同じ階層に置けば動きそうです。
「CDN からロードできるかな?」と思って調べてみたら、以下のページにたどり着けたので、こちらを利用してみます。
●jsqr CDN by jsDelivr - A CDN for npm and GitHub
https://www.jsdelivr.com/package/npm/jsqr
デモサイトの内容をローカルのファイルで動かす
デモサイトの内容をそのままコピーした後、スクリプトタグの部分を以下の内容にして動作させてみました(上記の CDN から、2021/8/1時点の最新バージョンでミニファイされたファイルをロード)。
<script src="https://cdn.jsdelivr.net/npm/jsqr@1.4.0/dist/jsQR.min.js"></script>
PC上で上記のファイルを動作させたので、今度はスマホで QRコードを生成し、読み込ませて動作確認をしました。そして問題なく動きました。
公式ライブラリのデモのソースを見てみる
「この後に何から独自の処理を加えたい場合、どこに手を入れれば良さそうか」というのを見てみました。
試しに動かしてみた時の挙動的には、以下のような処理を行っていると想像できました。
- カメラから画像を取得
- 取得したカメラ画像に対して QRコードの検出処理(画像上の位置情報を取得)
- QRコードのデコードも行う(検出されていた場合)
- 上記2 の結果が得られていた場合、取得したカメラ画像上に検出領域を示す線を重畳
- QRコードのデコード結果をページ上に表示
- カメラ画像の次のフレームを取得する(あとは、同様の処理を繰り返す)
ここからは、実際にソースコードを見ていって確認してみます。
カメラ画像の取得
上記1 に該当する部分は、以下のようでした。
// Use facingMode: environment to attemt to get the front camera on phones
navigator.mediaDevices.getUserMedia({ video: { facingMode: "environment" } }).then(function(stream) {
video.srcObject = stream;
video.setAttribute("playsinline", true); // required to tell iOS safari we don't want fullscreen
video.play();
requestAnimationFrame(tick);
});
カメラ画像の取得は、WebRTC等のカメラを利用する処理でおなじみの navigator.mediaDevices.getUserMedia
です。
その処理の中で、スマホで動かした時、内向き/外向きのカメラのうち、外向きのカメラを利用する設定をしているようでした。
具体的には facingMode: "environment"
を指定している部分です。
MDN の MediaDevices.getUserMedia() のページにも書いてあるとおりです(内向きのカメラを使うときは user
を指定)。
video.setAttribute("playsinline", true); // required to tell iOS safari we don't want fullscreen
の部分は、コメント内や以下のサイトにも書かれた iOS の Safari 向けの処理のようです。
●javascript - How to create an HTML5 video element in iOS that will stay hidden? - Stack Overflow
https://stackoverflow.com/questions/64415963/how-to-create-an-html5-video-element-in-ios-that-will-stay-hidden
なお、MDN のページで playsinline
について書かれた部分は以下となっています。
QRコードの検出・デコード
QRコードを検出したりデコードしたりする処理は、以下になりそうです。
canvas.drawImage(video, 0, 0, canvasElement.width, canvasElement.height);
var imageData = canvas.getImageData(0, 0, canvasElement.width, canvasElement.height);
var code = jsQR(imageData.data, imageData.width, imageData.height, {
inversionAttempts: "dontInvert",
});
カメラ画像をいったん Canvas に書き込み、 canvas.getImageData
でそれを取得した後に、 var code = jsQR...
という部分でライブラリ内での処理に引き渡しているようです。
また、オプションで {inversionAttempts: "dontInvert"}
と指定している部分は、公式のサイトに説明がありました。
このオプション指定について、「後方互換性のためにデフォルトを attemptBoth にしているものの、パフォーマンスの面から dontInvert を指定するのが良い(将来的には dontInvert をデフォルトにする)」といった説明が書いてありました。
QRコードが検出された場合の処理
QRコードに対する処理結果を格納した code
について、デモサイトのソースを読み解いて中身を確認する方法もありますが、ここではソースコードにログ出力の処理をつけたして、その出力された中身を見てみます。
Chrome で動作させ、ログを開発者ツールのコンソールに出してみました。
QRコードが検出されなかった場合は、 code が null になるようです。
一方、QRコードが検出がされた場合、検出した位置の情報( code.location
の部分)やデコード結果( code.data
の部分)が code の中に格納されるようです。
あらためてソースコードのほうも見てみると、そのあたりの情報を使った処理が以下の部分で見つかります。
if (code) {
drawLine(code.location.topLeftCorner, code.location.topRightCorner, "#FF3B58");
drawLine(code.location.topRightCorner, code.location.bottomRightCorner, "#FF3B58");
drawLine(code.location.bottomRightCorner, code.location.bottomLeftCorner, "#FF3B58");
drawLine(code.location.bottomLeftCorner, code.location.topLeftCorner, "#FF3B58");
...
outputData.innerText = code.data;
} else {
...
日本語の扱い
冒頭で書いた、日本語を QRコードに埋め込んだ場合に code.data の中身がどうなるかをログに出して確認してみました。
ちなみに、この時利用していたQRコード(「日本語」という文字を埋め込んだもの)は、以下の画像の内容でした。
この場合の code.data の中身は「空の文字列」になっているようでした。
公式ページの仕様が書かれていそうな部分(Return value の部分)を見ると、以下のような記載がありました。
上記を見ると、 code.data
が空の文字列になっている時も code.binaryData
にはデータが入っているように思われます。
そこで、 code.binaryData
をログに出力してみて、中身を見てみます。
以下は、「日本語」という文字列を QRコードに埋め込んだ場合と、「test」という文字列を QRコードに埋め込んだ場合の code.binaryData
のそれぞれの中身です。
バイナリデータのサイズ的には、うまくデータが取得できていそうに見えます(「日本語」は 2バイト文字が 3文字分で、test は 1バイトの文字が 4文字分)。
以下のサイトを参考にしつつ、Google Chrome の開発者ツールのコンソールで、デコードの処理を実行していろいろ試してみました。
- JavaScript バイナリデータの配列をUTF-8文字列へ変換する - Why it doesn't work?
- TextDecoder - Web API | MDN
- TextDecoder.prototype.encoding - Web APIs | MDN
その結果、分かったことは QRコードの日本語の文字列が Shift_JIS で、jsQR のデコード側が UTF-8 で文字列変換をしていそうだということでした。
以下に、Google Chrome の開発者ツールのコンソールで、「TextDecoder」を用いたデコードの処理を実行した際の、ソースコードと結果を掲載してみます。
var bytes = [147, 250, 150, 123, 140, 234];
var text_decoder = new TextDecoder('shift-jis');
var str = text_decoder.decode(Uint8Array.from(bytes).buffer);
console.log(str);
QRコード作成サイトについて少し検索してみると、例えば以下のサイトは文字コード指定も行えるようでした。
●QRコード作成| Softel labs
https://www.softel.co.jp/labs/tools/qr/
「日本語」という文字列を、UTF-8 で QRコード化してみます。
上記の画像を jsQR の公式デモで読ませた場合は、無事に日本語の文字もページ上の出力に表示されました。
jsQR の公式デモで、日本語の埋め込まれた QRコードを読み込ませる場合、
— you (@youtoy) August 1, 2021
QRコード生成側で UTF-8 を指定しておくと、画面上のデコード結果表示で失敗しなくてすむ。 https://t.co/RCShZLEoqk pic.twitter.com/XdMq83eVJn
そして、最初に利用していたサイト内で、こんな記載がありました。
最初に利用したサイト、日本語を Shift_JIS で埋め込んでたけど、同じサイト内での説明によるとこういうことらしい。 pic.twitter.com/aYtxX0a3Fo
— you (@youtoy) August 1, 2021
jsQR の処理では、この話に関係なく文字列への変換で UTF-8 を指定している実装になってる、という感じなのかな。
おわりに
JavaScript による QRコードのデコードについて、jsQR というライブラリを使うことで、簡単に実行できました。
ソースコードは、公式デモの実装がシンプルなもので、それを簡素化するというようなことはする必要がない状態でした。
そして、日本語の取り扱いに関する部分は少しハマったところがありましたが、内部実装についての知見が得られたので良かったです。
余談
かなり環境を選ぶようですが、以下の記事に出ていた Shape Detection API というものが気になったりもしています。
●続・Webの技術だけで作るQRコードリーダー - Qiita
https://qiita.com/kan_dai/items/3486880236a2fcd9b527
後で上記の記事や、以下の仕様のページなどを見ていければと思っています。
●Accelerated Shape Detection in Images
https://wicg.github.io/shape-detection-api/
【追記】 この後にやったこと 2つ
p5.js版も完成!(動作確認は PC のみ)
その後、p5.js を使って、ソースコードの行数を減らしたバージョンができました。
●p5.js と jsQR で簡素な QRコードのデコード処理:全体で40行ほど(試行錯誤の過程の情報も記載) - Qiita
https://qiita.com/youtoy/items/d7dd18e7127f23f482dd
QRコードのデコードと Teachable Machine での画像分類を同時に動かす
その後、Teachable Machine の画像分類(p5.js版)と jsQR による QRコードのデコードを、同時に動かす処理を試してみて、動作させることができました。
先ほどの #p5js を使ったカメラから映像内の QRコードのデコード( #jsQR を利用)は途中段階で、その後やりたかった事があり、その話について。
— you (@youtoy) August 1, 2021
やりたかった内容は、上記の QRコードのデコード処理と #TeachableMachine による画像分類(p5.js版)の同時並行での処理で、無事に動いた!! pic.twitter.com/U6EO2J2VHp