Solo IT Hub

長方形DataMatrix(DMRE)の比較照合を実現するまで — バーコードリーダー+Python

製品ラベルに印刷された2つの長方形DataMatrixコードを読み取り、内容が一致するか比較照合する。
出荷前の検品で必要になった作業ですが、既存のツールが見つからず、バーコードリーダーの設定でつまづき、最終的にPythonアプリを自作しました。
スマートフォンで使えるWeb版(Code Compare)も公開しています。
この記事では、バーコードリーダー+Pythonで実現するまでの過程を記録しています。

なお、この記事で「長方形のDataMatrix」と呼んでいるものは、正式にはDMRE(Data Matrix Rectangular Extension)といい、ISO/IEC 21471で規格化されています。
通常のDataMatrixは正方形ですが、DMREはそれを拡張し、8×48、20×36、22×48モジュールなどの長方形フォーマットを追加した規格です。
ISO/IEC 16022(通常のDataMatrix規格)にも一部の長方形サイズは含まれていますが、DMREではさらに多くのサイズが定義されています。

そもそも比較できるソフトが無い

最初にぶつかったのは、DMREを2つ比較照合できるソフトが見つからないという問題でした。

スマートフォンのアプリをいくつか試しましたが、長方形のDataMatrixが読めないものが多く、読めたとしても結果をテキスト表示するだけ。
2つのDataMatrixを比較できるアプリは見つかりませんでした。
探し方が悪いのかもしれませんが、かなりニッチな領域だと感じました。

無いなら作るしかないと思い、まずはWebアプリに挑戦しました。
しかし、手元にあったのは古いWebカメラで、解像度が低すぎて1回の読み取りに2分程度かかりました。
解像度の良いカメラを買うぐらいならと、バーコードリーダーを購入することにしました。

使用機材

DMREが読み取れない

HW0002を購入し、バーコードやQRコードを試すと問題なく読めました。
しかし、業務で使うDataMatrixを読み取ろうとすると反応しません。

HW0002の製品紹介にはDataMatrix対応と書いてあったのに、なぜ読めないのか。
調べてみると、DataMatrixには正方形と長方形の2種類が存在することをここで初めて知りました。
Webで正方形のDataMatrixのサンプルを見つけて試すと、それは読めます。
業務で使っているのは長方形のDataMatrix(DMRE)で、これだけが読めないと判明しました。

販売元のTeraに「長方形のDataMatrixが読み取れない」と問い合わせたところ、DMREを有効にするための設定用バーコードを提供してもらいました。
この設定用バーコードをHW0002で読み取るだけで、DMREが有効になります。
同梱の説明書にはこの設定方法の記載がありませんでした(当時の話なので、現在は不明です)。

設定用バーコード

以下が、Teraから提供された設定用バーコードです。
HW0002でこのバーコードを読み取ると、DMREの読み取りが有効になります。

長方形DataMatrixコード オン — Tera HW0002用設定バーコード

同じ問題にぶつかった方へ: バーコードリーダーでDMREが読めない場合、販売元に問い合わせると設定用バーコードを提供してもらえる可能性があります。

補足: 筆者が動作を確認したのは20×48モジュール(実測 約5mm×12mm)のDMREのみです。他のDMREフォーマットでの動作は未確認です。

QRコードを誤って読んでしまう

DMREが読めるようになった後、次の問題が発生しました。
製品ラベルにはDataMatrixとQRコードが近い位置に印刷されており、DataMatrixを読み取ろうとすると、近くのQRコードの方を読んでしまいます。

QRコードは3隅にファインダーパターン(大きな正方形マーカー)を持つため、一般的にDataMatrixより位置検出が速いとされています。
両方が視界に入ると、QRコードが先に認識されてしまうようです。

HW0002の設定で、QRコードの読み取りを無効化しました。
この設定方法は同梱のマニュアルに記載されています。
業務でQRコードを読む必要がなかったため、DataMatrixだけに反応する設定にすることで誤読がなくなりました。

Excelでの比較を断念

バーコードリーダーで読み取った文字列をExcelに入力し、関数で比較する方法をまず試しました。
当然、カーソルを毎回手で移動するのは手間なので、VBAマクロでセルの移動を自動化しましたが、何回か比較を繰り返していると、なぜかカーソルがどこかに飛んで行方不明になる現象が発生しました。
マクロの作りが悪いのかもしれませんが、「カーソルを特定のセルに確実に留めておく」こと自体がExcelでは面倒だと感じました。

他にも、マウスとバーコードリーダーの持ち替えが毎回発生することや、比較結果(OK/NG)が関数の表示だけで見落としやすいこともあり、Excelで作るのをあきらめました。

Pythonアプリを自作

Excelで感じた問題を解消するために、以下の方針でPythonアプリを作りました。

インストール

$ pip install PyQt5

ソースコード(BarcodeCheck_PyQt5.py)

# Windows専用(winsoundを使用)
from PyQt5.QtWidgets import (
    QApplication, QWidget, QLabel, QVBoxLayout,
    QLineEdit, QPushButton, QDialog
)
from PyQt5.QtCore import Qt, QTimer
from PyQt5.QtGui import QFont
import sys
import winsound


class BarcodeApp(QWidget):
    def __init__(self):
        super().__init__()
        self.reading_flag = 1       # 1回目 or 2回目
        self.flash_timer = QTimer()
        self.flash_state = False
        self.flash_timer.timeout.connect(self.toggle_flash)
        self.interim_timer = QTimer()
        self.interim_timer.timeout.connect(self.end_interim)
        self.countdown_timer = QTimer()
        self.countdown_timer.timeout.connect(self.update_countdown)
        self.in_interim = False
        self.interim_dialog = None
        self.initUI()

    def initUI(self):
        self.setWindowTitle('Barcode Reader Comparison')
        self.showFullScreen()

        layout = QVBoxLayout()
        layout.setSpacing(5)

        # バーコードリーダーからの入力を受け取る隠しフィールド
        self.barcode_entry = QLineEdit(self)
        self.barcode_entry.setStyleSheet('border: none;')
        self.barcode_entry.setFixedSize(1, 1)
        self.barcode_entry.returnPressed.connect(self.handle_barcode_entry)
        layout.addWidget(self.barcode_entry)

        # 1回目の読み取り表示欄
        self.label_1 = QLabel('1回目')
        self.label_1.setAlignment(Qt.AlignCenter)
        self.label_1.setFont(QFont("Helvetica", 48))
        layout.addWidget(self.label_1)
        self.entry_1 = QLineEdit(self)
        self.entry_1.setFont(QFont("Helvetica", 48))
        self.entry_1.setAlignment(Qt.AlignCenter)
        self.entry_1.setFocusPolicy(Qt.NoFocus)
        layout.addWidget(self.entry_1)

        # 2回目の読み取り表示欄
        self.label_2 = QLabel('2回目')
        self.label_2.setAlignment(Qt.AlignCenter)
        self.label_2.setFont(QFont("Helvetica", 48))
        layout.addWidget(self.label_2)
        self.entry_2 = QLineEdit(self)
        self.entry_2.setFont(QFont("Helvetica", 48))
        self.entry_2.setAlignment(Qt.AlignCenter)
        self.entry_2.setFocusPolicy(Qt.NoFocus)
        layout.addWidget(self.entry_2)

        # 結果表示(画面いっぱいにOK/NG)
        self.result_label = QLabel('')
        self.result_label.setAlignment(Qt.AlignCenter)
        self.result_label.setFont(QFont("Helvetica", 500))
        self.result_label.setStyleSheet("font-weight: bold;")
        layout.addWidget(self.result_label)

        # 終了ボタン
        self.exit_button = QPushButton('Exit', self)
        self.exit_button.setFont(QFont("Helvetica", 24))
        self.exit_button.clicked.connect(self.close)
        layout.addWidget(self.exit_button)

        self.setLayout(layout)
        self.barcode_entry.setFocus()

    def handle_barcode_entry(self):
        if self.in_interim:
            return

        barcode = self.barcode_entry.text().strip()
        if not barcode:
            self.barcode_entry.clear()
            return

        if self.reading_flag == 1:
            self.flash_timer.stop()  # 前回のNGフラッシュを停止
            self.entry_1.setText(barcode)
            self.result_label.clear()
            self.result_label.setStyleSheet('')
            self.entry_2.clear()
            self.reading_flag = 2
            self.start_interim()
        elif self.reading_flag == 2:
            self.entry_2.setText(barcode)
            self.compare_barcodes()
            self.reading_flag = 1

        self.barcode_entry.clear()
        self.barcode_entry.setFocus()

    def start_interim(self):
        self.in_interim = True
        self.interim_timer.start(3000)

        # 前回のダイアログが残っていたら閉じる
        if self.interim_dialog is not None:
            self.interim_dialog.close()
            self.interim_dialog.deleteLater()

        self.interim_dialog = QDialog(self)
        self.interim_dialog.setWindowTitle("インターバル中")
        self.interim_dialog.setFixedSize(900, 150)
        dialog_layout = QVBoxLayout()
        self.countdown_label = QLabel("3秒後に再開します...", self.interim_dialog)
        self.countdown_label.setFont(QFont("Helvetica", 24))
        self.countdown_label.setAlignment(Qt.AlignCenter)
        dialog_layout.addWidget(self.countdown_label)
        self.interim_dialog.setLayout(dialog_layout)
        self.interim_dialog.show()

        self.countdown_timer.stop()
        self.countdown_timer.start(1000)
        self.countdown_value = 3

    def update_countdown(self):
        self.countdown_value -= 1
        if self.countdown_value > 0:
            self.countdown_label.setText(f"{self.countdown_value}秒後に再開します...")
        else:
            self.countdown_timer.stop()

    def end_interim(self):
        self.in_interim = False
        self.interim_timer.stop()
        if self.interim_dialog is not None:
            self.interim_dialog.accept()

    def compare_barcodes(self):
        barcode1 = self.entry_1.text()
        barcode2 = self.entry_2.text()

        if barcode1 == barcode2:
            self.result_label.setText('OK')
            self.result_label.setStyleSheet(
                'color: green; background-color: lightgreen; font-weight: bold;')
            winsound.Beep(1000, 200)
        else:
            self.result_label.setText('NG')
            self.result_label.setStyleSheet(
                'color: red; background-color: pink; font-weight: bold;')
            winsound.Beep(500, 500)
            self.start_flash()

    def start_flash(self):
        self.flash_state = True
        self.flash_timer.start(500)

    def toggle_flash(self):
        if self.result_label.text() == 'NG':
            if self.flash_state:
                self.result_label.setStyleSheet(
                    'color: red; background-color: yellow; font-weight: bold;')
            else:
                self.result_label.setStyleSheet(
                    'color: red; background-color: pink; font-weight: bold;')
            self.flash_state = not self.flash_state
        else:
            self.flash_timer.stop()

    def mousePressEvent(self, event):
        """画面クリックでフォーカスを隠しフィールドに戻す"""
        self.barcode_entry.setFocus()
        super().mousePressEvent(event)


if __name__ == '__main__':
    app = QApplication(sys.argv)
    ex = BarcodeApp()
    ex.show()
    sys.exit(app.exec_())

コードのポイント

バーコードリーダーの入力をどう受け取るか

バーコードリーダーは、読み取ったデータをキーボード入力として送信し、最後にEnterキーを送ります。
この性質を利用して、1×1ピクセルの見えないテキストフィールドを画面に配置し、常にそこにフォーカスを当てています。

self.barcode_entry.setFixedSize(1, 1)  # 見えないサイズ
self.barcode_entry.returnPressed.connect(self.handle_barcode_entry)  # Enter = 読み取り完了

表示用フィールドには Qt.NoFocus を設定し、フォーカスが移動しないようにしています。
さらに、画面をクリックしてもフォーカスが隠しフィールドに戻るよう、mousePressEvent をオーバーライドしています。

def mousePressEvent(self, event):
    self.barcode_entry.setFocus()
    super().mousePressEvent(event)

Excelで苦労した「カーソルが行方不明になる問題」は、この仕組みで根本的に解消しました。

空読み取りの防止

バーコードリーダーがEnterキーだけを送信するケースに備えて、空文字列のチェックを入れています。

barcode = self.barcode_entry.text().strip()
if not barcode:
    self.barcode_entry.clear()
    return

なぜ3秒のインターバルが必要か

バーコードリーダーの読み取り速度はカメラより圧倒的に速いです。
1回目を読み取った直後に手元の別のコードが視界に入ると、操作者が意図しないタイミングで2回目として読み取ってしまうことがありました。

3秒のインターバルを設け、その間の入力を無視することで、操作者が2つ目のコードに移動する時間を確保しています。

self.interim_timer.start(3000)  # 3秒間は入力を無視

OK/NGの表示と音

フォントサイズ500の巨大表示と、音の高低でOK/NGを区別しています。
NG時は背景がピンクと黄色で交互にフラッシュします。

検品作業中は画面をずっと見ているわけではないので、音による通知が重要です。
OKは高い短い音(1000Hz, 200ms)、NGは低い長い音(500Hz, 500ms)で、聞き間違えにくくしています。

PCとバーコードリーダーが用意できない場合や、出先で使いたい場合は、スマートフォンのカメラで比較照合できるWebアプリも公開しています。

カメラでの読み取りはバーコードリーダーに比べて速度が劣りますが、機材が不要で、ブラウザだけで使えます。
大量の比較にはバーコードリーダー+Pythonアプリ、少量や出先での確認にはWeb版、と使い分けるのがおすすめです。