QRコードを読むツールやライブラリは世にあふれているが、商品の JAN コードや配送伝票の Code 128、書籍の ISBN(EAN-13)といった1次元バーコードをブラウザだけで読みたい、となると意外と選択肢が絞られる。しかも「PCに保存した画像から読みたい」「Webカメラでライブに読みたい」を両方サクッと済ませたい。
そこで、ZXing-js を使って1次元・2次元のバーコードを画像とカメラの両方から読み取れるツールをぱんだツールズの1機能として作った。商品コードや ISBN を外部サーバーに送らずに読めるのがポイント。
この記事では、ZXing-js を使ったブラウザ完結バーコードデコードの実装を、画像モードとカメラモードに分けて解説する。手書きの輝度変換やカメラの後片付けなど、地味だが効く部分も拾っていく。
ライブラリ選定:@zxing/library と @zxing/browser の役割分担
デコードエンジンには ZXing-js を使った。ZXing(Zebra Crossing)は Java 製の定番バーコードライブラリで、その JavaScript ポートが ZXing-js。パッケージが2つに分かれていて、役割が違う。
"@zxing/library": "^0.21.3",
"@zxing/browser": "^0.1.5"
-
@zxing/library— デコードのコア。MultiFormatReader・HybridBinarizer・RGBLuminanceSourceなどのアルゴリズム本体。フレームワーク非依存 -
@zxing/browser— ブラウザ向けのラッパー。BrowserMultiFormatReaderがカメラ(getUserMedia)や<video>要素との連携を面倒見てくれる
画像ファイルからの読み取りはコアの @zxing/library を直接叩き、カメラからの連続スキャンは @zxing/browser を使う、という使い分けにした。
対応形式は 13 種類。QR に加えて1次元系を幅広くカバーする。
export const POSSIBLE_BARCODE_FORMATS: readonly BarcodeFormat[] = [
BarcodeFormat.QR_CODE,
BarcodeFormat.EAN_13, BarcodeFormat.EAN_8,
BarcodeFormat.UPC_A, BarcodeFormat.UPC_E,
BarcodeFormat.CODE_128, BarcodeFormat.CODE_39, BarcodeFormat.CODE_93,
BarcodeFormat.ITF, BarcodeFormat.CODABAR,
BarcodeFormat.DATA_MATRIX, BarcodeFormat.PDF_417, BarcodeFormat.AZTEC,
]
読み取り対象の形式は DecodeHintType でリーダーに渡す。TRY_HARDER を立てると、多少傾いた・ぼやけた画像でも粘って探してくれる(その分わずかに遅くなる)。
export function createBarcodeHints(): Map<DecodeHintType, unknown> {
const hints = new Map<DecodeHintType, unknown>()
hints.set(DecodeHintType.POSSIBLE_FORMATS, POSSIBLE_BARCODE_FORMATS)
hints.set(DecodeHintType.TRY_HARDER, true)
return hints
}
形式を絞るのは精度にも効く。全形式を無制限に試すと誤検出が増えるので、扱いたい13形式に明示的に限定している。
画像モード:Canvas で輝度に変換してからデコードする
画像ファイルからの読み取りは、ZXing のコアに「グレースケールの輝度配列」を渡す必要がある。@zxing/library の RGBLuminanceSource は名前に反して RGBA のピクセル配列をそのまま受け取るわけではなく、1ピクセル1バイトの輝度(luminance)配列を期待する。なので Canvas の ImageData(RGBA 4バイト/ピクセル)を自前で輝度に畳む。
function imageDataToLuminances(data: Uint8ClampedArray, pixelCount: number): Uint8ClampedArray {
const luminances = new Uint8ClampedArray(pixelCount)
for (let i = 0, j = 0; j < pixelCount; i += 4, j++) {
// ITU-R BT.601 の輝度係数で RGB → グレースケール
luminances[j] = (data[i] * 0.299 + data[i + 1] * 0.587 + data[i + 2] * 0.114) | 0
}
return luminances
}
i が RGBA 配列(4バイトずつ)、j が輝度配列(1バイトずつ)のインデックス。0.299 / 0.587 / 0.114 は ITU-R BT.601 の輝度係数で、人間の目の感度(緑に敏感)に合わせた重み付け。末尾の | 0 はビット OR を使った整数化で、Math.floor 相当を高速に効かせている。アルファチャンネル(data[i + 3])は読み飛ばす。
輝度配列ができたら、ZXing のパイプラインに流す。
export function decodeBarcodeFromImageData(imageData: ImageData): BarcodeDecodeResult | null {
const { data, width, height } = imageData
if (width <= 0 || height <= 0) return null
const luminances = imageDataToLuminances(data, width * height)
const source = new RGBLuminanceSource(luminances, width, height)
const bitmap = new BinaryBitmap(new HybridBinarizer(source))
const reader = new MultiFormatReader()
reader.setHints(createBarcodeHints())
try {
const result = reader.decode(bitmap)
return {
data: result.getText(),
format: BarcodeFormat[result.getBarcodeFormat()],
isUrl: isValidUrl(result.getText()),
}
} catch {
return null // バーコードが見つからないと decode は例外を投げる
}
}
流れはこうなる。
-
RGBLuminanceSourceで輝度配列を ZXing の光源ソースに包む -
HybridBinarizerで2値化する(明暗のしきい値を局所的に決めるので、照明ムラに強い。1次元バーコード向けのGlobalHistogramBinarizerより精度が出やすい) -
BinaryBitmapにしてMultiFormatReader.decode()に渡す - 見つかれば結果、見つからなければ
decodeが 例外を投げる のでcatchでnullを返す
ZXing の decode は「見つからない」を戻り値ではなく例外で表現するクセがあるので、try/catch で握りつぶすのが定石。
呼び出し側は、ファイルを FileReader で読んで <img> に流し、naturalWidth/naturalHeight 等倍の Canvas に描いて getImageData を取るだけ。
img.onload = () => {
const canvas = canvasRef.current!
const ctx = canvas.getContext('2d')!
canvas.width = img.naturalWidth
canvas.height = img.naturalHeight
ctx.drawImage(img, 0, 0)
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
const decoded = decodeBarcodeFromImageData(imageData)
// decoded があれば result/format をセット、なければエラー表示
}
検出した形式名は BarcodeFormat[result.getBarcodeFormat()] で取れる。BarcodeFormat は数値 enum なので、enum の 逆引き(数値 → 名前文字列)で "EAN_13" や "CODE_128" といった表示用の文字列に変換できる。
カメラモード:decodeFromVideoDevice と後片付けの罠
カメラからのライブ読み取りは @zxing/browser の BrowserMultiFormatReader に任せる。decodeFromVideoDevice に <video> 要素とコールバックを渡すと、内部で getUserMedia を呼んでカメラ映像を流し、フレームごとにデコードを試みてくれる。
const codeReader = new BrowserMultiFormatReader(createBarcodeHints())
const controls = await codeReader.decodeFromVideoDevice(
undefined, // deviceId(undefined で既定のカメラ。スマホは背面カメラが選ばれやすい)
video, // 映像を流す <video> 要素
(decoded, err, scanControls) => {
if (!decoded) return
// 後片付けを state 更新より「先」に行う
scanControls.stop()
scannerControlsRef.current = null
setResult(decoded.getText())
setFormat(BarcodeFormat[decoded.getBarcodeFormat()])
setResultIsUrl(isValidUrl(decoded.getText()))
setCameraState('idle')
},
)
scannerControlsRef.current = controls
ここでハマりやすいのがスキャンの停止タイミング。コールバックはフレームごとに高頻度で呼ばれるので、バーコードを検出したら即座にスキャンを止めないと、同じコードに対してコールバックが連発する。そこで scanControls.stop() と ref の null 化を state 更新よりも先にやっている。これで二重停止や、停止済みコントロールへの再アクセス(null 上書き)を防ぐ。
そしてカメラ系で必ず必要なのが、ストリームの解放。読み取りタブから離れたとき・コンポーネントがアンマウントされたときに、カメラを止めないと録画ランプが点きっぱなしになる。controls を ref に持っておき、useEffect のクリーンアップで確実に停止する。
const stopCamera = useCallback(() => {
if (scannerControlsRef.current) {
scannerControlsRef.current.stop()
scannerControlsRef.current = null
}
setCameraState('idle')
}, [])
useEffect(() => {
if (mode !== 'camera') stopCamera()
return () => stopCamera() // アンマウント時も停止
}, [mode, stopCamera])
カメラ起動の失敗は種類が多いので、DOMException の name で分岐して、ユーザーが何をすればいいか分かるメッセージに落とす。
catch (err: unknown) {
if (err instanceof DOMException) {
if (err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError') {
setError('カメラへのアクセスが許可されていません。ブラウザの設定からカメラを許可してください。')
} else if (err.name === 'NotFoundError' || err.name === 'DevicesNotFoundError') {
setError('カメラが見つかりませんでした。カメラが接続されているか確認してください。')
} else {
setError('カメラの起動に失敗しました。別のアプリがカメラを使用中の可能性があります。')
}
}
}
NotAllowedError(権限拒否)と NotFoundError(デバイスなし)は出方が全然違うので、まとめて「失敗しました」にすると原因が分からず詰む。なお getUserMedia は HTTPS(または localhost)でしか動かないので、本番は HTTPS 必須。<video> には playsInline(iOS で全画面化させない)と muted を付けておくのも地味に重要。
まとめ
ブラウザ完結のバーコード読み取りは、ZXing-js の2パッケージを使い分けると素直に組める。
-
@zxing/library(コア)に画像モード、@zxing/browser(ラッパー)にカメラモードを担当させる - 画像モードは Canvas の
ImageDataを BT.601 係数で輝度配列に畳んでRGBLuminanceSourceに渡す。HybridBinarizerで照明ムラに強くなる -
decodeは「見つからない」を例外で返すのでtry/catchで握る - カメラモードは検出時に
stop()を state 更新より先に呼ぶ。useEffectクリーンアップで必ずストリームを解放する -
getUserMediaは HTTPS 必須、DOMException.nameで失敗理由を出し分ける
QR だけでなく JAN/EAN/Code128/ISBN まで1ツールで読めると、PCでの棚卸しや書籍管理が一気に楽になる。商品コードや内部コードをサーバーに送らずに読めるのも、地味だが効くポイントだ。
ぱんだツールズ では他にも PDF・画像・CSV・テキスト処理など、開発者向けのツールを多数公開している。全部無料・登録不要・ブラウザ完結で使える。
https://sakutto-panda.com
この記事は Zenn にも同じ内容を投稿しています。