PCのカメラでQRコードを読み取りPythonに取り込みHTMLで表示する

Pythonでバーコード、QRコードを読み取るのはすこぶる簡単ですが、その結果をリアルタイムに表示させる方法には一考の余地があります。

前回はExcelシートへの表示、HTMLとAjaxでの表示をやりましたが、今回はHTML方式を発展させて、WebSocketを使った高速リアルタイム更新で物品管理チックなことができるようにします。

前回記事はこちら。

システム概要

基本は前回のHTML+Ajaxを踏襲しますが、ところどころ改良を加えつつ一番のポイントはHTMLの更新データ取得をAjaxではなくWebSocket通信でやることです。

Ajaxでもサーバーへの問い合わせ間隔を短くすれば、ほぼリアルタイムで更新できそうですが、無駄が多く美しさに欠けます。対してWebSocketはそういう用途のための双方向通信規格なので問題になりません。

システムの概要図は次のとおりです。

WebSocketサーバーにはwebsocket-serverモジュールを、カメラ、バーコードにはそれぞれopencv-pythonpyzbarを使用します。インストールします。他は標準モジュールで対応できます。

pip install git+https://github.com/Pithikos/python-websocket-server
pip install opencv-python
pip install pyzbar

更新のリアルタイム感をより出すために、読み取り対象はQRコードで作成します。QRコードだとナナメでも逆さまでもとにかくカメラに写ればOKなので認識が速くなります。

管理表はSQLiteのテーブルで作成して、次のような内容です。

これをHTMLに変換、書き出して次のように表示します。

今回のシステムではさいたま市の区名コードをQRコードにして、それを読むと読み取った区名の状況がNGからOKに変わります。

デモ動画

では完成形がどのようなうごきになるか、先に見てもらうのが手っ取り早いのでご覧ください (動画・音がでます) 。

このようにリアルタイムで更新されます。QRコードだと認識精度も良いです。

実用例としては存在を管理したい物品にQRコードを貼っておいて、それをバシバシ写していけば、なくなったものはNGで残るのでわかりますね。小さいものなら動画のようにバサッとばらまいておいて写すだけなので一瞬で終わります。それにしてもpyzbar優秀すぎ。

さて、Pythonスクリプトですが前回のExcel編ではベタ書きしましたが、今回はWebSocketサーバー、HTTPサーバー、カメラ、データベースを操るため、さすがに管理が大変そうなのでクラスを使って書くことにします。

コードは次のようになりました。長いです。

import json
import sqlite3
import cv2
import winsound
import threading
from pyzbar.pyzbar import decode
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from websocket_server import WebsocketServer


class OrenoServer:

    def __init__(self):
        self.HOST = 'localhost'
        self.HTTP_PORT = 8080
        self.WS_PORT = 8081
        self.client = None
        self.wss = WebsocketServer(host=self.HOST, port=self.WS_PORT)
        self.wss.set_fn_new_client(self.new_client)
        self.https = ThreadingHTTPServer((self.HOST, self.HTTP_PORT), HttpHandler)
        self.codes = []

    def start(self):
        threading.Thread(target=self.wss.run_forever).start()
        threading.Thread(target=self.https.serve_forever).start()

    def shutdown(self):
        self.wss.shutdown()
        self.https.shutdown()

    def new_client(self, client, server):
        if self.client is None:
            self.client = client
            threading.Thread(target=self.cam_capture).start()

    def cam_capture(self):
        cap = cv2.VideoCapture(1)
        font = cv2.FONT_HERSHEY_SIMPLEX

        while cap.isOpened():
            ret, frame = cap.read()

            if ret:
                d = decode(frame)

                if d:
                    for barcode in d:
                        barcode_data = barcode.data.decode('utf-8')

                        if barcode_data not in self.codes:
                            self.codes.append(barcode_data)
                            winsound.Beep(2000, 50)
                            font_color = (0, 0, 255)
                            self.wss.send_message(self.client, barcode_data)
                        else:
                            font_color = (0, 154, 87)

                        x, y, w, h = barcode.rect
                        cv2.rectangle(frame, (x, y), (x + w, y + h), font_color, 2)
                        frame = cv2.putText(frame, barcode_data, (x, y - 10), 
                                            font, .5, font_color, 2, cv2.LINE_AA)

            cv2.imshow('QRCODE READER  press q -> exit', frame)

            if cv2.waitKey(1) & 0xFF == ord('q'):
                db = OrenoDataBase()
                db.set(self.codes)
                db.close()
                self.shutdown()
                break


class OrenoDataBase:

    def __init__(self):
        self.conn = sqlite3.connect(r'D:\saitama.sqlite')
        self.conn.row_factory = sqlite3.Row
        self.cur = self.conn.cursor()

    def get(self):
        self.cur.execute('SELECT * FROM saitama_city ORDER BY code')
        rows = []

        for r in self.cur.fetchall():
            rows.append({'name': r['name'], 'code': r['code'], 'status': r['status']})

        return rows

    def set(self, codes):
        place_holder = ','.join('?'*len(codes))
        values = tuple(codes)
        self.cur.execute(
            f'UPDATE saitama_city SET status = TRUE WHERE code in ({place_holder})', values)
        self.conn.commit()

    def close(self):
        self.cur.close()
        self.conn.close()


class HttpHandler(BaseHTTPRequestHandler):

    def do_GET(self):
        with open(r'D:\template.html', mode='r', encoding='utf-8') as html:
            response_body = html.read()
            self.send_response(200)
            self.send_header('Content-type', 'text/html; charset=utf-8')
            self.end_headers()
            self.wfile.write(response_body.encode('utf-8'))

    def do_POST(self):
        db = OrenoDataBase()
        rows = db.get()
        self.send_response(200)
        self.send_header('Content-type', 'application/json')
        self.end_headers()
        response_body = json.dumps(rows)
        self.wfile.write(response_body.encode('utf-8'))
        db.close()


server = OrenoServer()
server.start()

表示を担うHTMLは次のようになりました。template.htmlというファイル名です。

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    BootstrapCDN読み込み(省略)
    <script>
        const HTTP_PORT = 8080
        const WS_PORT = 8081

        let wss = new WebSocket('ws://localhost:' + WS_PORT)
        wss.onmessage = function (e) {
            $('#'+ e.data).removeClass('bg-danger').addClass('bg-success').text('OK')
        }

        $.ajax({
            url: 'http://localhost:' + HTTP_PORT,
            type: 'POST',
            dataType: 'json',
        }).then(
            function (data) {
                let elem = '<tr><th>区名</th><th>区名コード</th><th>状況</th></tr>'

                $.each(data, function (key, item) {
                    let bc
                    let status
                    if(item.status === 1){
                        bc = 'bg-success'
                        status = 'OK'
                    }else{
                        bc = 'bg-danger'
                        status = 'NG'
                    }
                    elem += '<tr>'
                    elem += '<td>' + item.name + '</td>'
                    elem += '<td>' + item.code + '</td>'
                    elem += '<td class="' + bc + '" id="' + item.code +'">' + status + '</td>'
                    elem += '</tr>'
                })

                $('#tb').html(elem)
            })
    </script>
    <title>さいたま市</title>
</head>
<body>
<div class="container">
    <table class="table table-striped table-dark" id="tb">
    </table>
</div>
</body>
</html>

解説

class OrenoServerはHTTPサーバーとWebSocketサーバーを管理します。イニシャライザでそれぞれのサーバーインスタンスを作成してhttps、wssフィールドへ格納します。

HTTPは8080、WebSocketは8081ポートで待ち受けます。アドレスはhttp://localhost:8080とws://localhost:8081となります。

もしかしたらHTTPとWebSocketを同じサーバーインスタンスに含めることができそうな気もするのですが、別々にしてしまったほうが簡単そうなので。

startメソッドを呼ぶとそれぞれのサーバーをスレッドとして起動します。


WebsocketServerインスタンスはWebブラウザーで利用者の端末(以下クライアント)がアクセスしてきたことをイベントとして検知することができ、set_fn_new_clientメソッドの引数に関数名を渡すと、その関数がイベントリスナーとして実行されるようになっています。

よってself.wss.set_fn_new_client(self.new_client)で、クライアントがWebSocketサーバーにアクセスしてくるとOrenoServerのnew_clientメソッドが実行されます。

new_clientメソッドは、self.clientがNoneの場合だけ実行されます。self.clientは初期値がNoneでクライアントがアクセスしてきた時点で、そのクライアントのインスタンスが格納されます。つまり、最初の1クライアント以外には対応しないことを意味します。

これは、カメラ制御の二重起動での衝突を防止するためです。ローカルで使うだけなので普通ないのですが、一応。

new_clientメソッドには引数が3つ設定されていますが、第3引数serverは使用していません。しかし使っていないからといって削除すると怒られます。

この引数はWebsocketServerのクライアント検知イベントから渡ってくるもので、こっちで勝手に省略できません。

IDEによっては

みたいに「おい、それ使ってねーぞ!」と警告されて気持ち悪いですが我慢しましょう。

ということで、最初のクライアントがアクセスしてくると、スレッドを作ってcam_captureメソッドを実行します。

cam_captureについてはやってることは前回と同じなので詳細は省略します。カメラを起動してQRコードがあれば、読み取り値をキャッシュself.codesと比較し、すでに読み取り済みは緑枠、はじめて読み取ったコードは赤枠+ビープ音でキャッシュへ確保します。

前回と異なるところは、新規コードはデータベースに書き込まず、そのままWebSocketからメッセージとして接続中のクライアントへ送信します。

それがコードの次の部分です。

if barcode_data not in self.codes:
    self.codes.append(barcode_data)
    winsound.Beep(2000, 50)
    font_color = (0, 0, 255)
    self.wss.send_message(self.client, barcode_data)
else:
    font_color = (0, 154, 87)

表示用ページは、http://localhost:8080で配信されています。ここへGETリクエストがあるとtemplate.htmlを読み込んでレスポンスします。これはclass HttpHandlerの仕事で詳しくは前回解説を参照ください。

ページが読み込まれるとHTMLに埋め込んだJavaScriptがws://localhost:8081とWebSocket接続を確立します。同時にサーバーからWebSocketメッセージを受信したときのイベントリスナーを設定します。それが次のJavaScriptコードです。

<script>
let wss = new WebSocket('ws://localhost:' + WS_PORT)
wss.onmessage = function (e) {
    $('#'+ e.data).removeClass('bg-danger').addClass('bg-success').text('OK')
}
</script>

イベントリスナーではサーバーから送られてきたメッセージ(QRコード読み取り値)と同じidの要素に対してCSSクラスの脱着、テキストの書き換えをしています。

じゃあ、そのidを付けた要素はどこなのよというと、その下のAjaxで作っています。

AjaxがPOSTでhttp://localhost:8080へリクエストします。POSTリクエストにはHttpHandlerがデータベースからデータを取得してJSONに変換してレスポンスします。

返ってきたJSONをJavaScriptでHTMLのテーブルに変換、組み立てしていきます。このとき状況列の<td>にidとして区名コードを付与します。

それが次のコードです。

<script>
$.ajax({
    url: 'http://localhost:' + HTTP_PORT,
    type: 'POST',
    dataType: 'json',
}).then(
    function (data) {
        let elem = '<tr><th>区名</th><th>区名コード</th><th>状況</th></tr>'
       
        $.each(data, function (key, item) {
            let bc
            let status
            // 状況によりクラス設定
            if(item.status === 1){
                bc = 'bg-success'
                status = 'OK'
            }else{
                bc = 'bg-danger'
                status = 'NG'
            }
            // テーブル組み立て
            elem += '<tr>'
            elem += '<td>' + item.name + '</td>'
            elem += '<td>' + item.code + '</td>'
            // 状況列に区名コードをidとして付与
            elem += '<td class="' + bc + '" id="' + item.code +'">' + status + '</td>'
            elem += '</tr>'
        })

        $('#tb').html(elem)
    })
</script>

このようにして、データベースのテーブルがHTMLのテーブルとして表示され、QRコード読み取り値から該当の区名コードの状況列へアクセスして書き換えています。

スクリプトを起動して、http://localhost:8080へブラウザでアクセスすると、冒頭のデモ動画のうごきになります。

言い忘れましたが、データベースへの接続管理はclass OrenoDataBaseでやっています。中身は大したことはないので、説明は端折ります。


用事が済んだらQキーを押すとcam_captureのループを抜けます。このときにOrenoDataBaseのsetメソッドへ読み取り値のキャッシュself.codesを渡すと、それをもとにUPDATEを実行してデータベースをHTMLのテーブルと同期させます。

最後にデータベースの接続断とサーバーのシャットダウンをして終了します。

OrenoServerのshutdownメソッドを呼ぶと、その時点でスクリプトが終了するので(作成したインスタンスが全部消滅するからかな?自分でもよくわかってない奴)breakはしなくても良さそうですが念のため残しています。

まとめ

前回の方法に比べてかなりスマートになりました。PythonのGUIモジュールはどうもイマイチで将来性もなさそう(個人的感想)なので、こういった方法でGUIを提供するのもありですね。

Python以外の言語に関する知識も必要になりますが、もしWEB系の言語を全くさわったことがないかたは、Pythonを使えるのであればHTMLは超簡単なので余裕と思われ、JavaScriptも基本的な思想はPythonと同じでイチから学習するより楽なはずで、CSSはBootstrapに丸投げればOKです。習得した知識はWEBスクレイピングなんかでも役立つのでいろいろはかどりオススメです。

おわり。