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',
|
'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.',
|
||||||
|
|||||||
@@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user