1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ブラウザだけでJAN/EAN/Code128バーコードを読む——ZXing-jsで画像とカメラ両対応の読み取りツールを作る

1
Posted at

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 — デコードのコア。MultiFormatReaderHybridBinarizerRGBLuminanceSource などのアルゴリズム本体。フレームワーク非依存
  • @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/libraryRGBLuminanceSource は名前に反して 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 は例外を投げる
  }
}

流れはこうなる。

  1. RGBLuminanceSource で輝度配列を ZXing の光源ソースに包む
  2. HybridBinarizer で2値化する(明暗のしきい値を局所的に決めるので、照明ムラに強い。1次元バーコード向けの GlobalHistogramBinarizer より精度が出やすい)
  3. BinaryBitmap にして MultiFormatReader.decode() に渡す
  4. 見つかれば結果、見つからなければ decode例外を投げる ので catchnull を返す

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/browserBrowserMultiFormatReader に任せる。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])

カメラ起動の失敗は種類が多いので、DOMExceptionname で分岐して、ユーザーが何をすればいいか分かるメッセージに落とす。

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(デバイスなし)は出方が全然違うので、まとめて「失敗しました」にすると原因が分からず詰む。なお getUserMediaHTTPS(または 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 にも同じ内容を投稿しています。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?