Help us understand the problem. What is going on with this article?

ブラウザでバーコード/QRコードリーダー【実装・カスタマイズ編】

More than 1 year has passed since last update.

キャプチャ.PNG

やりたいこと

  • Javascriptでバーコードリーダーを実装。
  • 出来るだけ多くのプラットフォームで動作させたい。
  • WebCodeCamJSというライブラリをベースにバーコードリーダーを実装する。
  • クライアントサイドで動作するため、サーバに余計な負荷をかけない。
  • WebCodeCamJSをカスタマイズさせて、より使いやすくする。
  • チェックデジットを自前で実装して、より誤検出をへらす。

そのうち記事を追記したりするかもしれません。

前提

前回の記事
https://qiita.com/mm_sys/items/a0dcc2c02f27751cd80d

WebRTCでwebカメラを使ってバーコードリーダーを実装するため、前回はそのテスト環境を作成しました。
しかし、そのテスト環境でなくてもHTTPSで接続出来る環境であれば今回の内容を実装できます。
webサイトの保存ディレクトリをhtdocsと表記しておりますので、各人の環境に読み替えてください。

WebCodeCamJSを展開

WebCodeCamJS 2.7.0 を利用してます。

GitHub - andrastoth/webcodecamjs: Demo page

上記URLにアクセスして、ダウンロード等して、htdocs内に展開してください。
screen001.png

jsファイルの構成

今回主に使うjavascriptファイル(jsフォルダ内のファイル)は以下のような構成になっています。

  • DecoderWorker.js::必須ファイルで変更不可
  • jquery.js
  • main.js::index.htmlから直接読み込まれるファイル。index.htmlのレイアウトのIDとかと直接関係がある記述がされている。レイアウト変更したらこのファイルの中身を変更する。
  • qrcodelib.js::名前の通り、QRコードを読み解くのに必要。変更不可
  • webcodecamjs.js::デコードや画像処理のメインファイル。バーコードリーダーの処理関係はとりあえずここを修正。

必要なファイルのみにする

前回の記事の最後のように、WebCodeCamJSを使ってバーコードリーダーの動作が確認できたとして、今回はシンプルな構成から色々カスタマイズしていきます。

jsディレクトリとaudio以外排除

htdocs内のjsと、audioおよびその中身のbeep.mp3という名前のファイルとディレクトリ以外、htdocsフォルダの中身を削除もしくは別フォルダに移動させてください。

シンプルなファイルを作る

カスタマイズしやすいようにシンプルなHTMLを実装します。
今回作成する仕様は、リアルタイム検出のみを行います。

HTMLを記述

jsと同じフォルダに以下の内容でindex.htmlという名前で保存。
ちなみに、bootstrapのCDNを使っているのでインターネットに接続されている状態でないと、レイアウトが崩れます。オフラインで使いたいときは、bootstrapが動作するように書き換えてください。

index.html
<!DOCTYPE html>
<html>

<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>simple</title>
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.3.7/css/bootstrap.min.css">
    <link rel="stylesheet"
          href="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.3.7/css/bootstrap-theme.min.css">
</head>

<body>
<div class="container">
    <div class="row">
        <div class="col-md-12">
            <span id="scanned-TYPE">CODE TYPE</span>
            <textarea rows="3" id="scanned-QR"></textarea>
        </div>
        <div class="col-md-12">
            <div class="thumbnail" id="result" style="width:320px;height:240px;max-width:320px;">
                <img id="scanned-img" style="width:100%;height:100%;min-height:150px;max-height:320px;">
            </div>
            <div>
                <canvas width="640" height="480" id="webcodecam-canvas"></canvas>
                <div class="scanner-laser laser-rightBottom" style="opacity:0.5;"></div>
                <div class="scanner-laser laser-rightTop" style="opacity:0.5;"></div>
                <div class="scanner-laser laser-leftBottom" style="opacity:0.5;"></div>
                <div class="scanner-laser laser-leftTop" style="opacity:0.5;"></div>
                <select id="camera-select" class="form-control"></select>
                <div class="btn-group" role="group">
                    <button class="btn btn-default" type="button" id="play">play</button>
                    <button class="btn btn-default" type="button" id="pause"><strong>pause</strong></button>
                    <button class="btn btn-default" type="button" id="stop"><strong>stop</strong><br></button>
                </div>
            </div>
        </div>
        <div class="col-md-12">
            <span class="label label-default" id="zoom-value">倍率 : 2</span>
            <input type="range" min="10" max="50" value="0" id="zoom" onchange="Page.changeZoom();"/>
            <span class="label label-default" id="brightness-value">明るさ : 20</span>
            <input type="range" value="20" min="0" max="128" id="brightness" onchange="Page.changeBrightness();"/>
            <span class="label label-default" id="contrast-value">コントラスト : 0</span>
            <input type="range" value="0" min="0" max="64" id="contrast" onchange="Page.changeContrast();"/>
            <span class="label label-default" id="threshold-value">2値化 : 0</span>
            <input type="range" value="0" min="0" max="512" id="threshold" onchange="Page.changeThreshold();"/>
            <span class="label label-default" id="sharpness-value">鋭化 : off</span>
            <input type="checkbox" id="sharpness" onchange="Page.changeSharpness();"/>
            <span class="label label-default" id="grayscale-value">白黒 : off</span>
            <input type="checkbox" id="grayscale" onchange="Page.changeGrayscale();"/>
            <span class="label label-default" id="flipVertical-value">垂直反転 : off</span>
            <input type="checkbox" id="flipVertical" onchange="Page.changeVertical();"/>
            <span class="label label-default" id="flipHorizontal-value">水平反転: off</span>
            <input type="checkbox" id="flipHorizontal" onchange="Page.changeHorizontal();"/>
        </div>
    </div>
</div>
<div>
使わないけど設置しておかないとエラーが表示されるボタン
<button id="decode-img"></button>
<button id="grab-img"></button>
</div>
<script type="text/javascript" src="js/qrcodelib.js"></script>
<script type="text/javascript" src="js/webcodecamjs.js"></script>
<script type="text/javascript" src="js/main.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.3.7/js/bootstrap.min.js"></script>
</body>

</html>

htdocsがこんな状態になったかと思います。

screen008.png

試しにアクセス

バーコードのほうは、このままではかなり認識率が悪いです。
QRコードは割と認識しやすいです。

カスタマイズ

ここから認識率の向上とつかやすくするためカスタマイズします。

os別に動作を変える

こちらのサイトを参照。
http://www9.plala.or.jp/oyoyon/html/script/platform.html

iOSの場合はリアカメラに設定する

iOSのカメラはフロントカメラが標準のようで、リアカメラは別に設定しないといけません。
webcodecamjs.jsvar WebCodeCamJS = function(element) {init()メソッド(93行目くらい)の直前くらいに以下を追記します。

webcodecamjs.js
// 略
            cameraError: function(error) {
                console.log(error);
            }
        };

    // ----ここから追記----
    function switchOS() {
        var os, ua = navigator.userAgent;
        if (ua.match(/iPhone|iPad/)) {
            // todo iOSのリアカメラ使うかどうかはここで設定
            options.constraints.video.facingMode = {exact: "environment"}; // リアカメラにアクセス};
        }
    }
    switchOS();
    // ----ここまで追記----

    function init() {
        var constraints = changeConstraints();
        try {
            mediaDevices.getUserMedia(constraints).then(cameraSuccess).catch(function(error) {
                options.cameraError(error);
                return false;
// 略

チェックデジットを追加して精度を上げる

JANコードなんかのバーコードの末尾は整合性を確認するための番号になっており、これの整合性を確認して精度を上げます。
webcodecamjs.jsvar WebCodeCamJS = function(element) {の適当なところに追記します。

webcodecamjs.js
// todo チェックデジット
    function checkDigit(barcodeStr, barCodeType) {
        // バーコードの種類ごとに処理を変える
        if (barCodeType === "EAN-13") {
            // 短縮用処理
            barcodeStr = ('00000' + barcodeStr).slice(-13);
            let evenNum = 0, oddNum = 0;
            for (var i = 0; i < barcodeStr.length - 1; i++) {
                if (i % 2 === 0) { // 「奇数」かどうか(0から始まるため、iの偶数と奇数が逆)
                    oddNum += parseInt(barcodeStr[i]);
                } else {
                    evenNum += parseInt(barcodeStr[i]);
                }
            }
            // 結果
            return 10 - parseInt((evenNum * 3 + oddNum).toString().slice(-1)) === parseInt(barcodeStr.slice(-1));
        }
        // 対象とならないバーコードの場合はtrue(確認せずに処理通過)で終了
        return true;
    }

webcodecamjs.jsの228行目くらいにあるsetCallBack内に1行追記

webcodecamjs.js
function setCallBack() {
        DecodeWorker.onmessage = function(e) {
            if (localImage || (!delayBool && !video.paused)) {
                if (e.data.success === true && e.data.success != 'localization') {
                    sucessLocalDecode = true;
                    delayBool = true;
                    delay();
                    setTimeout(function() {
                        if (options.codeRepetition || lastCode != e.data.result[0].Value) {
                            beep();
                            lastCode = e.data.result[0].Value;
                            if (!checkDigit(lastCode, e.data.result[0].Format)) return; // <- これを追記
                            options.resultFunction({
                                format: e.data.result[0].Format,
                                code: e.data.result[0].Value,
                                imgData: lastImageSrc
                            });
                        }
以下略

スキャンするサイズを変更する

webcodecamjs.jsの48行目くらいにあるwidthheightの値を変更します。

webcodecamjs.js

        options = {
            decodeQRCodeRate: 5,
            decodeBarCodeRate: 3,
            successTimeout: 500,
            codeRepetition: true,
            tryVertical: true,
            frameRate: 15,
            width: 640,<-変更
            height: 480,<-変更
            constraints: {
                video: {

スキャンする種類を限定して処理を軽く

手っ取り早いのはwebcodecamjs.jsdelay(144行目くらい)内の該当箇所をコメントアウト

webcodecamjs.js
    function delay() {
        delayBool = true;
        if (!localImage) {
            setTimeout(function() {
                delayBool = false;
                if (options.decodeBarCodeRate) {
                    tryParseBarCode();// <- バーコードをスキャンしたくないときはコメントアウト
                }
                if (options.decodeQRCodeRate) {
                    tryParseQRCode();// <- QRをスキャンしたくないときはコメントアウト
                }
            }, options.successTimeout);
        }
    }

また、279行目くらいにあるdecodeFormatsの使わないバーコードの種類を削除

webcodecamjs.js

        DecodeWorker.postMessage({
            scan: con.getImageData(0, 0, w, h).data,
            scanWidth: w,
            scanHeight: h,
            multiple: false,
            decodeFormats: ["Code128", "Code93", "Code39", "EAN-13", "2Of5", "Inter2Of5", "Codabar"],//<-ここを編集する
            rotation: flipMode[0]
        });

枠を描いて、その中だけスキャンする

これはちょっと手間がかかります。
まず、枠の座標を保存する変数を追加します。
41行目くらいに追記しました。

webcodecamjs.js

        delayBool = false,
        initialized = false,
        localStream = null,
        // ----ここから追記----
          // 640x480くらいのサイズの場合
        scanSize = {
            x: 80,
            y: 60,
            width: 320,
            height: 240,
        },
        // ----ここまで追記----
        options = {
            decodeQRCodeRate: 5,
            decodeBarCodeRate: 3,

次に、以下の関数を追記します。
私はconvolute関数の次くらいに記入しました。

webcodecamjs.js
// 枠の描画と2つの画像を合成する
    function drawRectangleAndMerge(screenPixels, pixels, x, y, w, h) {
        // 内側のピクセル
        var src = pixels.data,
            // 全体のピクセル
            screen = screenPixels.data,
            // 空のキャンバスを作成し、この中に結果を入れる
            tmpCanvas = document.createElement('canvas'),
            tmpCtx = tmpCanvas.getContext('2d'),
            output = tmpCtx.createImageData(screenPixels.width, screenPixels.height),
            dst = output.data;
        for (var tmp_y = 0; tmp_y < screenPixels.height; tmp_y++) {
            for (var tmp_x = 0; tmp_x < screenPixels.width; tmp_x++) {
                // 色をぬる座標設定
                var dstOff = (tmp_y * screenPixels.width + tmp_x) * 4;
                // 矩形の塗り範囲(この場合3pixel内側に赤く塗る)
                if (x < tmp_x && x + w > tmp_x && y < tmp_y && y + h > tmp_y// 外周の中であるか
                ) {
                    if (x + 3 < tmp_x && x + w - 3 > tmp_x && y + 3 < tmp_y && y + h - 3 > tmp_y)// 内周から3pixel内側か
                    {
                        // 枠の内側用の座標生成
                        var dstSrcOff = ((tmp_y - y) * pixels.width + (tmp_x - x)) * 4;
                        // 枠の内側の描画
                        dst[dstOff] = src[dstSrcOff]; // R
                        dst[dstOff + 1] = src[dstSrcOff + 1]; // G
                        dst[dstOff + 2] = src[dstSrcOff + 2]; // B
                        dst[dstOff + 3] = 255; // alpha
                    } else {
                        // 色の枠を描画
                        dst[dstOff] = 255; // R
                        dst[dstOff + 1] = 0; // G
                        dst[dstOff + 2] = 0; // B
                        dst[dstOff + 3] = 255; // alpha
                    }
                } else {
                    // 枠の外を描画
                    dst[dstOff] = screen[dstOff]; // R
                    dst[dstOff + 1] = screen[dstOff + 1]; // G
                    dst[dstOff + 2] = screen[dstOff + 2]; // B
                    dst[dstOff + 3] = 255; // alpha
                }
            }
        }
        return output;
    }

そして、setEventListenersを以下のように書き換えます。

webcodecamjs.js
function setEventListeners() {
        video.addEventListener('canplay', function(e) {
            if (!isStreaming) {
                if (video.videoWidth > 0) {
                    h = video.videoHeight / (video.videoWidth / w);
                }
                display.setAttribute('width', w);
                display.setAttribute('height', h);
                isStreaming = true;
                if (options.decodeQRCodeRate || options.decodeBarCodeRate) {
                    delay();
                }
            }
        }, false);
        video.addEventListener('play', function() {
            setInterval(function() {
                if (!video.paused && !video.ended) {
                    var z = options.zoom;
                    if (z === 0) {
                        z = optimalZoom();
                    }
                    con.drawImage(video, (w * z - w) / -2, (h * z - h) / -2, w * z, h * z);
                    // ↓これをコメントアウト
                    // var imageData = con.getImageData(0, 0, w, h);
                    // ↓かわりにこれを追記
                    var imageData = con.getImageData(scanSize.x, scanSize.y, scanSize.width, scanSize.height);
                    if (options.grayScale) {
                        imageData = grayScale(imageData);
                    }
                    if (options.brightness !== 0 || options.autoBrightnessValue) {
                        imageData = brightness(imageData, options.brightness);
                    }
                    if (options.contrast !== 0) {
                        imageData = contrast(imageData, options.contrast);
                    }
                    if (options.threshold !== 0) {
                        imageData = threshold(imageData, options.threshold);
                    }
                    if (options.sharpness.length !== 0) {
                        imageData = convolute(imageData, options.sharpness);
                    }
                    // todo ↓これを追記
                    imageData = drawRectangleAndMerge(con.getImageData(0, 0, w, h), imageData, scanSize.x, scanSize.y, scanSize.width, scanSize.height);

                    con.putImageData(imageData, 0, 0);
                }
            }, 1E3 / options.frameRate);
        }, false);
    }

それと、tryParseBarCode関数を下記のように修正

webcodecamjs.js
    function tryParseBarCode() {
        display.style.transform = 'scale(' + (options.flipHorizontal ? '-1' : '1') + ', ' + (options.flipVertical ? '-1' : '1') + ')';
        if (options.tryVertical && !localImage) {
            flipMode.push(flipMode[0]);
            flipMode.splice(0, 1);
        } else {
            flipMode = [1, 3, 6, 8];
        }
        lastImageSrc = display.toDataURL();
        DecodeWorker.postMessage({
            // ↓この3行をコメントアウト
            // scan: con.getImageData(0, 0, w, h).data,
            // scanWidth: w,
            // scanHeight: h,
            // ↓かわりにこれを追記
            scan: con.getImageData(
                scanSize.x,
                scanSize.y,
                scanSize.width,
                scanSize.height).data,
            scanWidth: scanSize.width,
            scanHeight: scanSize.height,
            // ここまで追記
            multiple: false,
            decodeFormats: ["Code128","EAN-13"],
            rotation: flipMode[0]
        });
    }

他にも色々変更できます。
自分に必要な機能を色々追加しましょう

mm_sys
deepoculus
ツカザキ病院眼科のAIチームです。医療AI(特に眼科領域)の開発を行うスタートアップ
https://www.tsukazaki-hp.jp/care/ophthalmology/ai
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした