diff --git a/_build_apk.sh b/_build_apk.sh new file mode 100644 index 0000000..f0118e1 --- /dev/null +++ b/_build_apk.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -e +cd /mnt/d/workbench/tauri-android-invoice +export ANDROID_HOME="$HOME/android-sdk" +export NDK_HOME="$(ls -d $HOME/android-sdk/ndk/* 2>/dev/null | head -1)" +export JAVA_HOME="${JAVA_HOME:-/home/cheney/.local/share/mise/installs/java/17.0.2}" +echo "ANDROID_HOME=$ANDROID_HOME" +echo "NDK_HOME=$NDK_HOME" +npm run tauri android android-studio-script --release --target aarch64 2>&1 | tail -n 15 diff --git a/qr-test.html b/qr-test.html new file mode 100644 index 0000000..43cecd0 --- /dev/null +++ b/qr-test.html @@ -0,0 +1,244 @@ + + + + + + QR 诊断测试 + + + + +

jsQR 二维码诊断工具

+ +
+ + + + + + +
+ +
+
+

原图

+ +
+
+

当前处理结果

+ +
+
+ + + + + + diff --git a/src/qrscan.js b/src/qrscan.js index b91482d..975f08c 100644 --- a/src/qrscan.js +++ b/src/qrscan.js @@ -1,7 +1,10 @@ -// 二维码解码核心逻辑:与 UI、相机、Tauri 解耦,便于单元测试。 +// 二维码解码核心逻辑:与 UI、相机、Tauri 解耦,便于单元测试。 // 相机扫码走原生插件,图片扫码走纯 JS 的 jsQR,桌面端也可用。 import jsQR from "jsqr"; +/** 多级缩放倍率,由大到小依次尝试,命中即停止。 */ +const SCALE_TRIES = [1, 0.5, 0.25, 0.125]; + /** * 校验扫码内容是否为有效网址(仅接受 http/https)。 * @param {unknown} content 扫码得到的原始内容。 @@ -14,8 +17,56 @@ export function extractUrl(content) { } /** - * 从像素数据中解码二维码。 - * @param {{data: Uint8ClampedArray, width: number, height: number}} imageData + * 纯像素运算将 ImageData 等比缩放至指定倍率(不依赖 Canvas API,可测试)。 + * 对每个输出像素取对应输入区域的平均值,适合缩小场景。 + * @param {{data:Uint8ClampedArray,width:number,height:number}} src 源像素数据。 + * @param {number} scale 缩放倍率(0.5 = 一半,0.25 = 四分之一)。 + * @returns {{data:Uint8ClampedArray,width:number,height:number}} 缩放后的像素数据。 + */ +export function scaleImageData(src, scale) { + if (scale >= 1) return src; + const sw = src.width; + const sh = src.height; + const dw = Math.max(1, Math.floor(sw * scale)); + const dh = Math.max(1, Math.floor(sh * scale)); + const srcData = src.data; + const out = new Uint8ClampedArray(dw * dh * 4); + const xRatio = sw / dw; + const yRatio = sh / dh; + for (let dy = 0; dy < dh; dy += 1) { + const sy0 = Math.floor(dy * yRatio); + const sy1 = Math.min(sh, Math.floor((dy + 1) * yRatio)); + for (let dx = 0; dx < dw; dx += 1) { + const sx0 = Math.floor(dx * xRatio); + const sx1 = Math.min(sw, Math.floor((dx + 1) * xRatio)); + let r = 0, g = 0, b = 0, a = 0, count = 0; + for (let sy = sy0; sy < sy1; sy += 1) { + for (let sx = sx0; sx < sx1; sx += 1) { + const idx = (sy * sw + sx) * 4; + r += srcData[idx]; + g += srcData[idx + 1]; + b += srcData[idx + 2]; + a += srcData[idx + 3]; + count += 1; + } + } + const oi = (dy * dw + dx) * 4; + if (count > 0) { + out[oi] = Math.round(r / count); + out[oi + 1] = Math.round(g / count); + out[oi + 2] = Math.round(b / count); + out[oi + 3] = Math.round(a / count); + } + } + } + return { data: out, width: dw, height: dh }; +} + +/** + * 从像素数据中解码二维码(多级缩放回退)。 + * 对手机拍摄的高分辨率照片,二维码可能只占很小区域,jsQR 全图扫描容易失败。 + * 本函数先尝试原始尺寸,失败后依次等比缩小再试,大幅提升实际场景识别率。 + * @param {{data:Uint8ClampedArray,width:number,height:number}} imageData * 画布像素数据(RGBA)。 * @returns {string|null} 解码到的文本;未识别到返回 null。 */ @@ -23,11 +74,15 @@ export function decodeQrFromImageData(imageData) { if (!imageData || !imageData.data || !imageData.width || !imageData.height) { return null; } - // attemptBoth 允许识别反色(浅底深码或深底浅码)二维码,提升清晰码的识别率。 - const result = jsQR(imageData.data, imageData.width, imageData.height, { - inversionAttempts: "attemptBoth", - }); - return result ? result.data : null; + const opts = { inversionAttempts: "attemptBoth" }; + let current = imageData; + for (const scale of SCALE_TRIES) { + const result = jsQR(current.data, current.width, current.height, opts); + if (result) return result.data; + if (scale === SCALE_TRIES[SCALE_TRIES.length - 1]) break; + current = scaleImageData(current, 0.5); // 每次缩一半 + } + return null; } /** diff --git a/src/views/Scan.vue b/src/views/Scan.vue index 0e41d44..aa76080 100644 --- a/src/views/Scan.vue +++ b/src/views/Scan.vue @@ -1,4 +1,4 @@ - diff --git a/tests/images/二维码1.jpg b/tests/images/二维码1.jpg new file mode 100644 index 0000000..d6abe8a Binary files /dev/null and b/tests/images/二维码1.jpg differ diff --git a/tests/images/二维码2.jpg b/tests/images/二维码2.jpg new file mode 100644 index 0000000..bab4281 Binary files /dev/null and b/tests/images/二维码2.jpg differ diff --git a/tests/qrscan.test.js b/tests/qrscan.test.js index 0f4fba3..b9b5776 100644 --- a/tests/qrscan.test.js +++ b/tests/qrscan.test.js @@ -1,8 +1,9 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect } from "vitest"; import QRCode from "qrcode"; import { extractUrl, decodeQrFromImageData, + scaleImageData, } from "../src/qrscan.js"; /** @@ -18,11 +19,10 @@ function renderQrImageData(text, scale = 8, quiet = 4) { const modules = qr.modules.data; const dim = (size + quiet * 2) * scale; const data = new Uint8ClampedArray(dim * dim * 4); - // 先整体填白(含静区)。 data.fill(255); for (let row = 0; row < size; row += 1) { for (let col = 0; col < size; col += 1) { - if (!modules[row * size + col]) continue; // 0 = 浅色,跳过 + if (!modules[row * size + col]) continue; const x0 = (col + quiet) * scale; const y0 = (row + quiet) * scale; for (let dy = 0; dy < scale; dy += 1) { @@ -39,6 +39,45 @@ function renderQrImageData(text, scale = 8, quiet = 4) { return { data, width: dim, height: dim }; } +/** + * 将小二维码嵌入大画布中央,模拟手机拍摄的远景照片(二维码占比小)。 + * @param {string} text 二维码内容。 + * @param {number} canvasW 大画布宽度。 + * @param {number} canvasH 大画布高度。 + * @returns {ImageData} 大画布像素数据。 + */ +function embedSmallQr(text, canvasW = 600, canvasH = 400) { + const qr = QRCode.create(text, { errorCorrectionLevel: "M" }); + const size = qr.modules.size; + const modules = qr.modules.data; + const scale = 3; // 每个模块 3px,二维码约 80px + const quiet = 1; + const qrW = (size + quiet * 2) * scale; + const qrH = qrW; + const ox = Math.floor((canvasW - qrW) / 2); + const oy = Math.floor((canvasH - qrH) / 2); + + const data = new Uint8ClampedArray(canvasW * canvasH * 4); + data.fill(255); + for (let row = 0; row < size; row += 1) { + for (let col = 0; col < size; col += 1) { + if (!modules[row * size + col]) continue; + const x0 = ox + (col + quiet) * scale; + const y0 = oy + (row + quiet) * scale; + for (let dy = 0; dy < scale; dy += 1) { + for (let dx = 0; dx < scale; dx += 1) { + const idx = ((y0 + dy) * canvasW + (x0 + dx)) * 4; + data[idx] = 0; + data[idx + 1] = 0; + data[idx + 2] = 0; + data[idx + 3] = 255; + } + } + } + } + return { data, width: canvasW, height: canvasH }; +} + describe("qrscan", () => { // 4-1 网址校验:接受 http/https,拒绝其它内容。 it("4-1 extractUrl 仅接受 http/https 网址", () => { @@ -68,4 +107,25 @@ describe("qrscan", () => { expect(decodeQrFromImageData(blank)).toBeNull(); expect(decodeQrFromImageData(null)).toBeNull(); }); + + // 4-4 多级缩放回退:大画布中小二维码,原始尺寸失败但缩小后成功。 + it("4-4 多级缩放回退能识别大图中的小二维码", () => { + const url = "https://inv.example.com/small"; + const imageData = embedSmallQr(url, 800, 600); + const result = decodeQrFromImageData(imageData); + expect(result).toBe(url); + }); + + // 4-5 scaleImageData 等比缩放正确。 + it("4-5 scaleImageData 等比缩放", () => { + const src = renderQrImageData("test", 4); + const half = scaleImageData(src, 0.5); + expect(half.width).toBe(Math.floor(src.width * 0.5)); + expect(half.height).toBe(Math.floor(src.height * 0.5)); + expect(half.data).toBeInstanceOf(Uint8ClampedArray); + expect(half.data.length).toBe(half.width * half.height * 4); + expect(half.height).toBe(Math.floor(src.height * 0.5)); + }); }); + +