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:
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Shop Floor',
|
||||
'version': '19.0.23.0.0',
|
||||
'version': '19.0.24.0.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, '
|
||||
'first-piece inspection gates.',
|
||||
|
||||
@@ -30,6 +30,11 @@
|
||||
import { Component, useState, useRef, onWillUnmount } from "@odoo/owl";
|
||||
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 {
|
||||
static template = "fusion_plating_shopfloor.QrScanner";
|
||||
static props = {
|
||||
@@ -257,23 +262,20 @@ export class QrScanner extends Component {
|
||||
return;
|
||||
}
|
||||
const Z = window.ZXing;
|
||||
const reader = new Z.BrowserMultiFormatReader();
|
||||
this._zxingReader = reader;
|
||||
|
||||
// Restrict to QR codes — skips Code128 / EAN / PDF417 probes
|
||||
// we don't need, halves per-frame work.
|
||||
try {
|
||||
// Pass hints via the constructor — assignment to .hints
|
||||
// afterward doesn't work because decodeBitmap reads from
|
||||
// this._hints (set by MultiFormatReader.setHints during
|
||||
// construction). TRY_HARDER makes the QR finder more
|
||||
// aggressive about perspective and contrast.
|
||||
const hints = new Map();
|
||||
// DecodeHintType.POSSIBLE_FORMATS == 2 in the zxing-js enum.
|
||||
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.
|
||||
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);
|
||||
// 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
|
||||
// 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.
|
||||
*
|
||||
|
||||
@@ -64,6 +64,19 @@
|
||||
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_warn,
|
||||
.o_fp_qr_detected,
|
||||
|
||||
@@ -36,6 +36,25 @@
|
||||
</div>
|
||||
<video t-if="state.canScan" t-ref="video"
|
||||
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">
|
||||
<i class="fa fa-check-circle me-1"/>
|
||||
<span>Detected: </span>
|
||||
|
||||
Reference in New Issue
Block a user