ЧЗ scanner: switch decoder to zxing-wasm (ZXing-C++) for reliable DataMatrix

The pure-JS @zxing/library locator is weak on real ЧЗ codes (decoded 1/4 of the
test photos with correct grayscale input; libdmtx and zxing-wasm read 3/4 — the
4th is physically undecodable). Symptom: scanner takes very long to even find a
code, 2 of 4 never read.

- Bundle zxing-wasm reader as an IIFE (frontend/lib/zxing-wasm.js, global
  ZXingWASM) + zxing_reader.wasm; drop frontend/lib/zxing.js. Init fetches the
  .wasm as an arrayBuffer and hands it over as wasmBinary (no streaming/MIME/CDN).
- ChZ.zxReady()/ChZ.decodeImageData() (chzn.js): decode an ImageData -> raw GS1
  bytes (result.bytes keeps the GS separators) with tryHarder/tryRotate/tryInvert.
- Scanner (main.js) rewritten to a canvas loop: crop the central ROI, upscale to
  540px, decode each frame — full-frame search was the slow/unreliable part.
  Photo path decodes the picked image the same way.

Not yet verified on-device (WebView WASM instantiation); decoder logic validated
in a Node harness against the 4 sample photos.
This commit is contained in:
4aiman 2026-08-27 09:42:05 +03:00
commit 5f30af1cf7
6 changed files with 2190 additions and 37 deletions

View file

@ -18,7 +18,7 @@
<script src="lib/chart.min.js"></script>
<script src="lib/chartjs-plugin-zoom.min.js"></script>
<script src="lib/jszip.min.js"></script>
<script src="lib/zxing.js"></script>
<script src="lib/zxing-wasm.js"></script>
<script src="js/db.js"></script>
<script src="js/bmr.js"></script>
<script src="js/tilecache.js"></script>

View file

@ -270,9 +270,40 @@ let ChZ = (function () {
return prefill;
}
// ------------------------------------------------------------------
// DataMatrix decoding via bundled zxing-wasm (ZXing-C++). Far better at
// LOCATING codes than the pure-JS port. The .wasm is fetched once as an
// arrayBuffer and handed to the module (wasmBinary) — no streaming/MIME issues.
// ------------------------------------------------------------------
let _zxReadyP = null;
function zxReady() {
if (_zxReadyP) return _zxReadyP;
_zxReadyP = (async () => {
if (typeof ZXingWASM === "undefined") throw new Error("ZXingWASM не загружен (пересобери приложение)");
let buf = await (await fetch("lib/zxing_reader.wasm")).arrayBuffer();
await ZXingWASM.prepareZXingModule({ overrides: { wasmBinary: new Uint8Array(buf) }, fireImmediately: true });
return true;
})();
return _zxReadyP;
}
// Decode one ImageData -> raw GS1 code string (with GS 0x1D), or null.
async function decodeImageData(imageData, formats) {
let res = await ZXingWASM.readBarcodes(imageData, {
formats: formats || ["DataMatrix"],
tryHarder: true, tryRotate: true, tryInvert: true, maxNumberOfSymbols: 1,
});
if (!res || !res.length || !res[0].isValid) return null;
let r = res[0];
// raw payload from bytes keeps the GS separators; fall back to text
if (r.bytes && r.bytes.length) return String.fromCharCode.apply(null, r.bytes);
return r.text || null;
}
return {
getSession, setSession, hasSession, importFromText, refresh, ensureSid,
check, nutritionInfo, normalizeCode, extractGtin, parseCard, resolve,
zxReady, decodeImageData,
_raw: raw,
};
})();

View file

@ -1844,16 +1844,16 @@ function showAddProductModal(onSaved, prefill) {
});
}
// Честный знак scanner: camera + photo/manual, decoded by bundled ZXing
// (frontend/lib/zxing.js) — fully offline, no Google Play Services (the native
// BarcodeDetector needs GMS and is absent on de-Googled ROMs). On a decoded
// DataMatrix -> ChZ.resolve -> prefill the new-product modal.
// Честный знак scanner: camera + photo/manual, decoded by bundled zxing-wasm
// (ZXing-C++: frontend/lib/zxing-wasm.js + zxing_reader.wasm) — fully offline,
// no Google Play Services, and far better at LOCATING a DataMatrix than the
// pure-JS port. Live frames are cropped to the central ROI and upscaled for
// speed/reliability. On decode -> ChZ.resolve -> prefill the new-product modal.
function showChznScanModal(onSaved) {
if (!ChZ.hasSession()) {
appAlert("Сессия Честного знака не импортирована.\nНастройки → Честный знак → Импорт сессии (файл chzn_session.json из tools/chzn_export.sh).");
return;
}
let hasZX = (typeof ZXing !== "undefined" && ZXing.BrowserDatamatrixCodeReader);
let ov = document.createElement("div");
ov.className = "modal-overlay";
@ -1864,7 +1864,7 @@ function showChznScanModal(onSaved) {
<h3>Скан «Честный знак»</h3>
<div style="position:relative;background:#000;border-radius:8px;overflow:hidden;aspect-ratio:1/1;max-height:60vh">
<video id="cz-video" playsinline muted style="width:100%;height:100%;object-fit:cover"></video>
<div style="position:absolute;inset:15%;border:2px solid rgba(255,255,255,.7);border-radius:8px;pointer-events:none"></div>
<div style="position:absolute;inset:14%;border:2px solid rgba(255,255,255,.7);border-radius:8px;pointer-events:none"></div>
</div>
<div id="cz-status" style="margin:10px 0;font-size:13px;color:var(--muted);min-height:18px">Запуск камеры...</div>
<div style="display:flex;gap:8px">
@ -1875,21 +1875,15 @@ function showChznScanModal(onSaved) {
</div>`;
document.body.appendChild(ov);
let controls = null, busy = false;
let stream = null, scanning = false, busy = false;
let base = winStack.length;
let statusEl = ov.querySelector("#cz-status");
let video = ov.querySelector("#cz-video");
let reader = null;
if (hasZX) {
let hints = new Map();
hints.set(ZXing.DecodeHintType.TRY_HARDER, true); // better on curved/low-contrast codes
reader = new ZXing.BrowserDatamatrixCodeReader(hints, { delayBetweenScanAttempts: 120 });
}
let canvas = document.createElement("canvas");
let cctx = canvas.getContext("2d", { willReadFrequently: true });
function stopCamera() {
try { if (controls) controls.stop(); } catch {}
controls = null;
scanning = false;
try {
let s = video && video.srcObject;
if (s && s.getTracks) s.getTracks().forEach(t => t.stop());
@ -1906,8 +1900,7 @@ function showChznScanModal(onSaved) {
async function onDecoded(rawVal) {
if (busy) return;
busy = true;
try { if (controls) controls.stop(); } catch {}
busy = true; scanning = false;
statusEl.textContent = "Код распознан, запрашиваю КБЖУ...";
try {
let prefill = await ChZ.resolve(rawVal);
@ -1917,20 +1910,51 @@ function showChznScanModal(onSaved) {
} catch (e) {
busy = false;
statusEl.textContent = (e.notFound ? "Код не найден в Честном знаке. " : "Ошибка: " + e.message + ". ") + "Наведите ещё раз.";
if (reader) startCamera(); // resume continuous scan
if (stream) { scanning = true; tick(); }
}
}
// Central ROI of the current video frame, upscaled to a square -> ImageData.
// Cropping to the guide box removes clutter and makes the code larger, which
// is exactly what the locator needs (full-frame search was slow/unreliable).
function grabROI() {
let vw = video.videoWidth, vh = video.videoHeight;
if (!vw || !vh) return null;
let side = Math.min(vw, vh) * 0.72;
let sx = (vw - side) / 2, sy = (vh - side) / 2;
let out = 540;
canvas.width = out; canvas.height = out;
cctx.drawImage(video, sx, sy, side, side, 0, 0, out, out);
return cctx.getImageData(0, 0, out, out);
}
async function tick() {
if (busy || !scanning) return;
let img = grabROI();
if (img) {
try {
let raw = await ChZ.decodeImageData(img, ["DataMatrix"]);
if (raw) { onDecoded(raw); return; }
} catch (e) { /* keep scanning */ }
}
if (scanning) setTimeout(tick, 100);
}
async function startCamera() {
if (!reader) { statusEl.textContent = "Декодер ZXing не загрузился — пересобери приложение."; return; }
try {
statusEl.textContent = "Загрузка декодера...";
await ChZ.zxReady();
} catch (e) {
statusEl.textContent = "Декодер не загрузился: " + (e.message || e);
return;
}
try {
stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: { ideal: "environment" } } });
video.srcObject = stream;
await video.play();
scanning = true;
statusEl.textContent = "Наведите на код Честного знака...";
controls = await reader.decodeFromConstraints(
{ video: { facingMode: { ideal: "environment" } } },
video,
(result) => { if (result && !busy) onDecoded(result.getText()); }
// per-frame NotFoundException is normal — ignored
);
tick();
} catch (e) {
statusEl.textContent = "Камера недоступна (" + (e.name || e.message) + "). Выберите фото или введите код.";
}
@ -1938,16 +1962,18 @@ function showChznScanModal(onSaved) {
ov.querySelector("#cz-photo").onclick = () => {
pickFile("image/*", async (buf) => {
if (!reader) { statusEl.textContent = "Декодер ZXing не загрузился."; return; }
let url = URL.createObjectURL(new Blob([buf]));
try {
let result = await reader.decodeFromImageUrl(url);
onDecoded(result.getText());
} catch (e) {
statusEl.textContent = (e && e.name === "NotFoundException")
? "Код на фото не распознан. Попробуйте более резкое фото."
: "Ошибка распознавания: " + (e.message || e);
} finally { URL.revokeObjectURL(url); }
await ChZ.zxReady();
let bmp = await createImageBitmap(new Blob([buf]));
let c = document.createElement("canvas");
c.width = bmp.width; c.height = bmp.height;
let x = c.getContext("2d", { willReadFrequently: true });
x.drawImage(bmp, 0, 0);
let img = x.getImageData(0, 0, bmp.width, bmp.height);
let raw = await ChZ.decodeImageData(img, ["DataMatrix", "QRCode"]);
if (raw) onDecoded(raw);
else statusEl.textContent = "Код на фото не распознан. Попробуйте более резкое/крупное фото.";
} catch (e) { statusEl.textContent = "Ошибка распознавания: " + (e.message || e); }
}, true);
};
ov.querySelector("#cz-manual").onclick = async () => {

2097
frontend/lib/zxing-wasm.js Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

Binary file not shown.