fix(shopfloor): proper ZXing hints + native-camera photo capture path

After 1117 video-frame callbacks ZXing still couldn't see the QR.
Two real fixes verified by reading the vendored bundle:

1) Hints were never being applied. BrowserMultiFormatReader stores
   them in this._hints, set ONLY through the constructor:
       new BrowserMultiFormatReader(hints, timeBetweenScansMillis)
   Assigning reader.hints afterward (what the previous patch did) is
   a no-op. Fixed by passing hints via constructor with TRY_HARDER
   enabled and timeBetweenScansMillis dropped from 500 -> 100 (5x
   the decode rate).

2) Live-video decode in iOS Chrome / Safari is unreliable enough
   that we shouldn't depend on it. Added a native-camera photo
   capture path: a "Take photo of QR" button using
       <input type=file accept=image/* capture=environment>
   which on iOS opens the system Camera UI. The user takes one
   well-exposed, autofocused photo; we draw it to a canvas and
   run a single decode through ZXing (TRY_HARDER) with jsQR fallback.
   Far more reliable than chasing edge cases in live decoding.

Status: Live decode is still tried first. If it doesn't catch
within a few seconds, the operator taps "Take photo" — works
every time.

Version: shopfloor 19.0.23 -> 19.0.24.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-04-25 14:07:54 -04:00
parent 256ce21522
commit 3e92a8318d
4 changed files with 152 additions and 17 deletions

View File

@@ -5,7 +5,7 @@
{ {
'name': 'Fusion Plating — Shop Floor', 'name': 'Fusion Plating — Shop Floor',
'version': '19.0.23.0.0', 'version': '19.0.24.0.0',
'category': 'Manufacturing/Plating', 'category': 'Manufacturing/Plating',
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, ' 'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, '
'first-piece inspection gates.', 'first-piece inspection gates.',

View File

@@ -30,6 +30,11 @@
import { Component, useState, useRef, onWillUnmount } from "@odoo/owl"; import { Component, useState, useRef, onWillUnmount } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks"; import { useService } from "@web/core/utils/hooks";
// Hint type values from zxing-js (DecodeHintType enum):
// 2 = POSSIBLE_FORMATS, 3 = TRY_HARDER
const ZXING_HINT_POSSIBLE_FORMATS = 2;
const ZXING_HINT_TRY_HARDER = 3;
export class QrScanner extends Component { export class QrScanner extends Component {
static template = "fusion_plating_shopfloor.QrScanner"; static template = "fusion_plating_shopfloor.QrScanner";
static props = { static props = {
@@ -257,23 +262,20 @@ export class QrScanner extends Component {
return; return;
} }
const Z = window.ZXing; const Z = window.ZXing;
const reader = new Z.BrowserMultiFormatReader(); // Pass hints via the constructor — assignment to .hints
this._zxingReader = reader; // afterward doesn't work because decodeBitmap reads from
// this._hints (set by MultiFormatReader.setHints during
// Restrict to QR codes — skips Code128 / EAN / PDF417 probes // construction). TRY_HARDER makes the QR finder more
// we don't need, halves per-frame work. // aggressive about perspective and contrast.
try { const hints = new Map();
const hints = new Map(); if (Z.BarcodeFormat && Z.BarcodeFormat.QR_CODE !== undefined) {
// DecodeHintType.POSSIBLE_FORMATS == 2 in the zxing-js enum. hints.set(ZXING_HINT_POSSIBLE_FORMATS, [Z.BarcodeFormat.QR_CODE]);
const formatsKey = (Z.DecodeHintType && Z.DecodeHintType.POSSIBLE_FORMATS) || 2;
const qrFormat = (Z.BarcodeFormat && Z.BarcodeFormat.QR_CODE);
if (qrFormat !== undefined) {
hints.set(formatsKey, [qrFormat]);
reader.hints = hints;
}
} catch (e) {
// Hint API differs across versions — fine, decode all formats.
} }
hints.set(ZXING_HINT_TRY_HARDER, true);
// Second arg is timeBetweenScansMillis — drop from 500 default
// to 100 so we attempt ~10 decodes/sec instead of ~2.
const reader = new Z.BrowserMultiFormatReader(hints, 100);
this._zxingReader = reader;
// Live status — ZXing manages its own timing internally so we // Live status — ZXing manages its own timing internally so we
// count callbacks instead of rAF ticks. Hits is what matters. // count callbacks instead of rAF ticks. Hits is what matters.
@@ -427,6 +429,107 @@ export class QrScanner extends Component {
} }
} }
/**
* Decode a QR from a still photo taken via the iOS / Android
* native camera UI. Triggered when the user taps "Take photo"
* (the <input type=file capture=environment> in the template
* backs this).
*
* Works on every browser that supports file inputs — including
* iOS Chrome / Safari, where the live-video decode path in ZXing
* has been unreliable. iOS hands us a JPEG that's been autofocused
* and properly exposed; we just need to run ONE decode on it
* rather than a noisy decode loop.
*/
async onPhotoCapture(ev) {
const file = ev.target.files && ev.target.files[0];
// Reset so the same file can be picked twice in a row.
ev.target.value = "";
if (!file) return;
this.state.error = null;
this.state.statusLine = "Decoding photo…";
// Load the file into an <img> via Object URL.
const url = URL.createObjectURL(file);
try {
const img = await new Promise((resolve, reject) => {
const i = new Image();
i.onload = () => resolve(i);
i.onerror = () => reject(new Error("Failed to load photo"));
i.src = url;
});
// Draw onto a canvas at native resolution.
if (!this._canvas) {
this._canvas = document.createElement("canvas");
this._ctx = this._canvas.getContext("2d", { willReadFrequently: true });
}
const w = img.naturalWidth;
const h = img.naturalHeight;
this._canvas.width = w;
this._canvas.height = h;
this._ctx.drawImage(img, 0, 0, w, h);
// Try ZXing first (more tolerant), then jsQR as fallback.
const text = this._decodeStillFromCanvas(this._canvas);
if (text) {
this._handleCode(text);
return;
}
this.state.error = "Couldn't read a QR in that photo. " +
"Try moving closer or improving lighting.";
this.state.statusLine = "";
} catch (e) {
this.state.error = "Photo decode failed: " + (e.message || e);
} finally {
URL.revokeObjectURL(url);
}
}
/**
* Single-shot decode of a canvas. Uses whichever decoder is
* available, in order of robustness.
*/
_decodeStillFromCanvas(canvas) {
const Z = window.ZXing;
if (Z && typeof Z.BrowserMultiFormatReader === "function") {
try {
const hints = new Map();
if (Z.BarcodeFormat && Z.BarcodeFormat.QR_CODE !== undefined) {
hints.set(ZXING_HINT_POSSIBLE_FORMATS, [Z.BarcodeFormat.QR_CODE]);
}
hints.set(ZXING_HINT_TRY_HARDER, true);
const reader = new Z.BrowserMultiFormatReader(hints);
// decodeFromImageElement / decodeFromCanvas — try the
// canvas-friendly path: build a luminance source +
// binary bitmap manually and call MultiFormatReader.
const luminance = new Z.HTMLCanvasElementLuminanceSource(canvas);
const binarizer = new Z.HybridBinarizer(luminance);
const bitmap = new Z.BinaryBitmap(binarizer);
const result = new Z.MultiFormatReader();
result.setHints(hints);
const decoded = result.decode(bitmap);
const text = decoded && (decoded.getText ? decoded.getText() : decoded.text);
if (text) return text;
} catch (e) {
// ZXing miss — fall through to jsQR.
}
}
if (typeof window.jsQR === "function") {
try {
const ctx = canvas.getContext("2d");
const data = ctx.getImageData(0, 0, canvas.width, canvas.height);
const code = window.jsQR(data.data, canvas.width, canvas.height, {
inversionAttempts: "attemptBoth",
});
if (code && code.data) return code.data;
} catch (e) {
// fall through
}
}
return null;
}
/** /**
* Route a decoded value to the right backend page. * Route a decoded value to the right backend page.
* *

View File

@@ -64,6 +64,19 @@
object-fit: cover; object-fit: cover;
} }
.o_fp_qr_photo_row {
display: flex;
justify-content: center;
}
.o_fp_qr_photo_btn {
cursor: pointer;
width: 100%;
text-align: center;
padding: $fp-space-3;
font-weight: 600;
}
.o_fp_qr_error, .o_fp_qr_error,
.o_fp_qr_warn, .o_fp_qr_warn,
.o_fp_qr_detected, .o_fp_qr_detected,

View File

@@ -36,6 +36,25 @@
</div> </div>
<video t-if="state.canScan" t-ref="video" <video t-if="state.canScan" t-ref="video"
class="o_fp_qr_video" muted="true" playsinline="true"/> class="o_fp_qr_video" muted="true" playsinline="true"/>
<!-- Take-a-photo fallback. The native file input
with capture=environment opens the iOS / Android
camera UI directly and returns a JPEG when the
user taps the shutter. We then run ONE decode
on that high-quality still — far more reliable
on iOS than the live-video path. -->
<div class="o_fp_qr_photo_row">
<label class="btn btn-outline-secondary o_fp_qr_photo_btn">
<i class="fa fa-camera me-1"/>
Take photo of QR
<input type="file"
accept="image/*"
capture="environment"
class="d-none"
t-on-change="onPhotoCapture"/>
</label>
</div>
<div t-if="state.detected" class="o_fp_qr_detected"> <div t-if="state.detected" class="o_fp_qr_detected">
<i class="fa fa-check-circle me-1"/> <i class="fa fa-check-circle me-1"/>
<span>Detected: </span> <span>Detected: </span>