/** * paper-recognizer.js * 尿检试纸识别模块 — 纯 JS 实现 * * 零外部依赖,兼容 Node.js 和浏览器。 * API 设计与 WASM 版 (miniappA) 保持一致。 * * 用法: * const { PaperRecognizer } = require('./paper-recognizer.js'); * const m = new PaperRecognizer(); * const result = m.recognize(rgbaPixels, width, height); * console.log(result.json); * m.free(); */ // ============================================================ // 图像处理基本操作(纯 JS) // ============================================================ const Img = { /** RGBA → 灰度 */ toGray(data, w, h) { const out = new Float64Array(w * h); for (let y = 0; y < h; y++) { for (let x = 0; x < w; x++) { const i = (y * w + x) * 4; const r = data[i], g = data[i + 1], b = data[i + 2]; out[y * w + x] = 0.299 * r + 0.587 * g + 0.114 * b; } } return out; }, /** BGRA → 灰度 */ bgraToGray(data, w, h) { const out = new Float64Array(w * h); for (let y = 0; y < h; y++) { for (let x = 0; x < w; x++) { const i = (y * w + x) * 4; const b = data[i], g = data[i + 1], r = data[i + 2]; out[y * w + x] = 0.299 * r + 0.587 * g + 0.114 * b; } } return out; }, /** RGBA → BGR(返回 [b,g,r,b,g,r,...]) */ bgrFromRgba(data, w, h) { const out = new Float64Array(w * h * 3); for (let y = 0; y < h; y++) { for (let x = 0; x < w; x++) { const i = (y * w + x) * 4; const j = (y * w + x) * 3; out[j] = data[i + 2]; // B out[j + 1] = data[i + 1]; // G out[j + 2] = data[i]; // R } } return out; }, /** BGRA → BGR */ bgrFromBgra(data, w, h) { const out = new Float64Array(w * h * 3); for (let y = 0; y < h; y++) { for (let x = 0; x < w; x++) { const i = (y * w + x) * 4; const j = (y * w + x) * 3; out[j] = data[i]; out[j + 1] = data[i + 1]; out[j + 2] = data[i + 2]; } } return out; }, /** 获取单像素 BGR */ getBgr(buf, w, h, x, y) { if (x < 0 || x >= w || y < 0 || y >= h) return [0, 0, 0]; const j = (y * w + x) * 3; return [buf[j], buf[j + 1], buf[j + 2]]; }, /** RGB → HSV */ rgbToHsv(r, g, b) { r /= 255; g /= 255; b /= 255; const mx = Math.max(r, g, b), mn = Math.min(r, g, b), delta = mx - mn; let h = 0, s = 0, v = mx; if (delta > 1e-10) { s = delta / mx; if (mx === r) h = ((g - b) / delta + (g < b ? 6 : 0)) * 60; else if (mx === g) h = ((b - r) / delta + 2) * 60; else h = ((r - g) / delta + 4) * 60; } return [h, s * 255, v * 255]; }, /** BGR → HSV */ bgrToHsv(b, g, r) { return Img.rgbToHsv(r, g, b); }, /** 图像 BGR → HSV(float 数组) */ bgrImageToHsv(bgr, w, h) { const out = new Float64Array(w * h * 3); for (let y = 0; y < h; y++) { for (let x = 0; x < w; x++) { const j = (y * w + x) * 3; const [h, s, v] = Img.bgrToHsv(bgr[j], bgr[j + 1], bgr[j + 2]); out[j] = h; out[j + 1] = s; out[j + 2] = v; } } return out; }, /** BGR → LAB */ bgrToLab(b, g, r) { let rr = r / 255, gg = g / 255, bb = b / 255; const gamma = v => v > 0.04045 ? Math.pow((v + 0.055) / 1.055, 2.4) : v / 12.92; rr = gamma(rr); gg = gamma(gg); bb = gamma(bb); const x = rr * 0.4124564 + gg * 0.3575761 + bb * 0.1804375; const y = rr * 0.2126729 + gg * 0.7151522 + bb * 0.0721750; const z = rr * 0.0193339 + gg * 0.1191920 + bb * 0.9503041; const xn = 0.95047, yn = 1.0, zn = 1.08883; const f = t => t > 0.008856 ? Math.pow(t, 1 / 3) : (7.787 * t + 16 / 116); const fx = f(x / xn), fy = f(y / yn), fz = f(z / zn); return [116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)]; }, /** 高斯模糊 5×5 */ gaussianBlur5(gray, w, h) { const kernel = [ [1, 4, 7, 4, 1], [4, 16, 26, 16, 4], [7, 26, 41, 26, 7], [4, 16, 26, 16, 4], [1, 4, 7, 4, 1] ]; const sum = 273, out = new Float64Array(w * h); for (let y = 0; y < h; y++) { for (let x = 0; x < w; x++) { let acc = 0; for (let ky = -2; ky <= 2; ky++) { const yy = y + ky; if (yy < 0 || yy >= h) continue; for (let kx = -2; kx <= 2; kx++) { const xx = x + kx; if (xx < 0 || xx >= w) continue; acc += gray[yy * w + xx] * kernel[ky + 2][kx + 2]; } } out[y * w + x] = acc / sum; } } return out; }, /** 高斯模糊 3×3 */ gaussianBlur3(gray, w, h) { const kernel = [1, 2, 1, 2, 4, 2, 1, 2, 1], sum = 16, out = new Float64Array(w * h); for (let y = 0; y < h; y++) { for (let x = 0; x < w; x++) { let acc = 0; for (let ky = -1; ky <= 1; ky++) { const yy = y + ky; if (yy < 0 || yy >= h) continue; for (let kx = -1; kx <= 1; kx++) { const xx = x + kx; if (xx < 0 || xx >= w) continue; acc += gray[yy * w + xx] * kernel[(ky + 1) * 3 + (kx + 1)]; } } out[y * w + x] = acc / sum; } } return out; }, /** Laplacian 3×3 */ laplacian(gray, w, h) { const kernel = [0, -1, 0, -1, 4, -1, 0, -1, 0]; const out = new Float64Array(w * h); for (let y = 0; y < h; y++) { for (let x = 0; x < w; x++) { let acc = 0; for (let ky = -1; ky <= 1; ky++) { const yy = y + ky; if (yy < 0 || yy >= h) continue; for (let kx = -1; kx <= 1; kx++) { const xx = x + kx; if (xx < 0 || xx >= w) continue; acc += gray[yy * w + xx] * kernel[(ky + 1) * 3 + (kx + 1)]; } } out[y * w + x] = acc; } } return out; }, /** 均值与标准差 */ meanStdDev(buf, len) { let sum = 0; for (let i = 0; i < len; i++) sum += buf[i]; const mean = sum / len; let sqSum = 0; for (let i = 0; i < len; i++) sqSum += (buf[i] - mean) * (buf[i] - mean); return { mean, stddev: Math.sqrt(sqSum / len) }; }, /** 区域内的值 */ roiValues(buf, w, h, x1, y1, x2, y2) { const values = []; const x0 = Math.max(0, x1), y0 = Math.max(0, y1); const x9 = Math.min(w - 1, x2), y9 = Math.min(h - 1, y2); for (let y = y0; y <= y9; y++) { for (let x = x0; x <= x9; x++) values.push(buf[y * w + x]); } return values; }, /** HSV inRange */ hsvInRange(hsvBuf, w, h, hLow, hHigh, sLow, sHigh, vLow, vHigh) { const out = new Uint8Array(w * h); for (let i = 0; i < w * h; i++) { const j = i * 3, hh = hsvBuf[j], ss = hsvBuf[j + 1], vv = hsvBuf[j + 2]; let inH; if (hLow <= hHigh) inH = hh >= hLow && hh <= hHigh; else inH = hh >= hLow || hh <= hHigh; out[i] = (inH && ss >= sLow && ss <= sHigh && vv >= vLow && vv <= vHigh) ? 255 : 0; } return out; }, /** 灰度 inRange */ inRange(gray, w, h, low, high) { const out = new Uint8Array(w * h); for (let i = 0; i < w * h; i++) out[i] = (gray[i] >= low && gray[i] <= high) ? 255 : 0; return out; }, /** 形态学膨胀 */ dilate(binary, w, h, ksize) { const half = Math.floor(ksize / 2), out = new Uint8Array(w * h); for (let y = 0; y < h; y++) { for (let x = 0; x < w; x++) { let max = 0; for (let ky = -half; ky <= half; ky++) { const yy = y + ky; if (yy < 0 || yy >= h) continue; for (let kx = -half; kx <= half; kx++) { const xx = x + kx; if (xx < 0 || xx >= w) continue; if (binary[yy * w + xx] > max) max = binary[yy * w + xx]; } } out[y * w + x] = max; } } return out; }, /** 形态学腐蚀 */ erode(binary, w, h, ksize) { const half = Math.floor(ksize / 2), out = new Uint8Array(w * h); for (let y = 0; y < h; y++) { for (let x = 0; x < w; x++) { let min = 255; for (let ky = -half; ky <= half; ky++) { const yy = y + ky; if (yy < 0 || yy >= h) continue; for (let kx = -half; kx <= half; kx++) { const xx = x + kx; if (xx < 0 || xx >= w) continue; if (binary[yy * w + xx] < min) min = binary[yy * w + xx]; } } out[y * w + x] = min; } } return out; }, /** 闭运算: 膨胀→腐蚀 */ morphClose(binary, w, h, ksize) { return Img.erode(Img.dilate(binary, w, h, ksize), w, h, ksize); }, /** 开运算: 腐蚀→膨胀 */ morphOpen(binary, w, h, ksize) { return Img.dilate(Img.erode(binary, w, h, ksize), w, h, ksize); } }; // ============================================================ // 连通分量标注与轮廓提取 // ============================================================ /** 二值图连通分量标注 (4-连通) */ Img.labelComponents = function (binary, w, h) { const labels = new Int32Array(w * h); for (let i = 0; i < w * h; i++) labels[i] = -1; let currentLabel = 0; const eq = []; const find = (x) => { while (eq[x] !== x) { eq[x] = eq[eq[x]]; x = eq[x]; } return x; }; const union = (a, b) => { const ra = find(a), rb = find(b); if (ra < rb) eq[rb] = ra; else eq[ra] = rb; }; for (let y = 0; y < h; y++) { for (let x = 0; x < w; x++) { if (binary[y * w + x] === 0) continue; const left = x > 0 ? labels[y * w + x - 1] : -1; const top = y > 0 ? labels[(y - 1) * w + x] : -1; if (left === -1 && top === -1) { labels[y * w + x] = currentLabel; eq[currentLabel] = currentLabel; currentLabel++; } else if (left !== -1 && top === -1) { labels[y * w + x] = left; } else if (left === -1 && top !== -1) { labels[y * w + x] = top; } else { labels[y * w + x] = Math.min(left, top); if (left !== top) union(left, top); } } } const labelMap = new Int32Array(currentLabel); for (let i = 0; i < currentLabel; i++) labelMap[i] = -1; let nextId = 0; const compPixels = []; for (let i = 0; i < currentLabel; i++) { const root = find(i); if (labelMap[root] === -1) { labelMap[root] = nextId++; compPixels.push([]); } } for (let y = 0; y < h; y++) { for (let x = 0; x < w; x++) { const l = labels[y * w + x]; if (l === -1) continue; const newLabel = labelMap[find(l)]; labels[y * w + x] = newLabel; compPixels[newLabel].push({ x, y }); } } return { labels, numComponents: nextId, pixels: compPixels }; }; /** Moore-Neighbor 边界追踪 (标准实现) */ Img.traceContour = function (binary, w, h, startX, startY) { const contour = []; // 方向: 0=右, 1=右下, 2=下, 3=左下, 4=左, 5=左上, 6=上, 7=右上 const dirs = [[1,0],[1,1],[0,1],[-1,1],[-1,0],[-1,-1],[0,-1],[1,-1]]; let cx = startX, cy = startY; // 初始 backtrack direction: 从上边进入 (方向6=上) let backtrack = 6; let maxSteps = w * h * 4; while (maxSteps-- > 0) { contour.push({ x: cx, y: cy }); let found = false; // 从 backtrack 顺时针搜索邻居 for (let i = 1; i <= 8; i++) { const nd = (backtrack + i) % 8; const nx = cx + dirs[nd][0]; const ny = cy + dirs[nd][1]; if (nx >= 0 && nx < w && ny >= 0 && ny < h && binary[ny * w + nx] > 0) { cx = nx; cy = ny; backtrack = (nd + 4) % 8; // 反方向 found = true; break; } } if (!found) return contour; // 回到起点则停止 if (cx === startX && cy === startY) { if (contour.length > 3) break; } } return contour; }; /** 查找所有外轮廓 */ Img.findContours = function (binary, w, h) { const { numComponents, pixels } = Img.labelComponents(binary, w, h); const contours = []; for (let compId = 0; compId < numComponents; compId++) { const comp = pixels[compId]; if (comp.length < 5) continue; let minY = h, bestX = -1, bestY = -1; for (const p of comp) { if (p.y < minY) { minY = p.y; bestX = p.x; bestY = p.y; } } for (const p of comp) { if (p.y === minY && (bestX < 0 || p.x < bestX)) { bestX = p.x; bestY = p.y; } } const contour = Img.traceContour(binary, w, h, bestX, bestY); if (contour.length >= 3) contours.push(contour); } return contours; }; // ============================================================ // Polygon / 旋转矩形计算 // ============================================================ /** 多边形面积 (Shoelace) */ Img.contourArea = function (pts) { let area = 0; for (let i = 0; i < pts.length; i++) { const j = (i + 1) % pts.length; area += pts[i].x * pts[j].y - pts[j].x * pts[i].y; } return Math.abs(area) / 2; }; /** 轴对齐包围盒 */ Img.boundingRect = function (pts) { let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; for (const p of pts) { if (p.x < minX) minX = p.x; if (p.y < minY) minY = p.y; if (p.x > maxX) maxX = p.x; if (p.y > maxY) maxY = p.y; } return { x: minX, y: minY, w: maxX - minX + 1, h: maxY - minY + 1 }; }; /** 凸包 (Andrew's Monotone Chain) */ Img.convexHull = function (pts) { if (pts.length < 3) return pts.slice(); const sorted = pts.slice().sort((a, b) => a.x - b.x || a.y - b.y); const cross = (o, a, b) => (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x); const lower = []; for (const p of sorted) { while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], p) <= 0) lower.pop(); lower.push(p); } const upper = []; for (const p of sorted.reverse()) { while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], p) <= 0) upper.pop(); upper.push(p); } lower.pop(); upper.pop(); return lower.concat(upper); }; /** 最小面积外接矩形 (Rotating Calipers) */ Img.minAreaRect = function (pts) { const hull = Img.convexHull(pts); const n = hull.length; if (n < 3) { const rect = Img.boundingRect(pts); return { center: { x: rect.x + rect.w / 2, y: rect.y + rect.h / 2 }, size: { w: rect.w, h: rect.h }, angle: 0 }; } let minArea = Infinity, bestRect = null; for (let i = 0; i < n; i++) { const p1 = hull[i], p2 = hull[(i + 1) % n]; const dx = p2.x - p1.x, dy = p2.y - p1.y; const len = Math.sqrt(dx * dx + dy * dy); if (len < 1) continue; const cosA = dx / len, sinA = dy / len; let minProj = Infinity, maxProj = -Infinity; let minPerp = Infinity, maxPerp = -Infinity; for (const p of hull) { const px = p.x - p1.x, py = p.y - p1.y; const proj = px * cosA + py * sinA; const perp = -px * sinA + py * cosA; if (proj < minProj) minProj = proj; if (proj > maxProj) maxProj = proj; if (perp < minPerp) minPerp = perp; if (perp > maxPerp) maxPerp = perp; } const w = maxProj - minProj, h2 = maxPerp - minPerp; const area = w * h2; if (area < minArea) { minArea = area; const cx = p1.x + cosA * (minProj + maxProj) / 2 - sinA * (minPerp + maxPerp) / 2; const cy = p1.y + sinA * (minProj + maxProj) / 2 + cosA * (minPerp + maxPerp) / 2; let fw = w, fh = h2, angle = Math.atan2(sinA, cosA) * 180 / Math.PI; if (fw < fh) { [fw, fh] = [fh, fw]; angle += 90; } bestRect = { center: { x: cx, y: cy }, size: { w: fw, h: fh }, angle }; } } return bestRect || { center: { x: 0, y: 0 }, size: { w: 0, h: 0 }, angle: 0 }; }; /** 矩形度 */ Img.rectangularity = function (pts, rotatedRect) { const area = Img.contourArea(pts); const rectArea = rotatedRect.size.w * rotatedRect.size.h; return rectArea > 0 ? area / rectArea : 0; }; // ============================================================ // 常量 // ============================================================ const PST = { UNKNOWN: 0, PST_14_S: 1, PST_14_L: 2, PST_11_S: 3, PST_11_L: 4, PST_8_S: 5, PST_8_L: 6 }; const PST_NAMES = { 0:'UNKNOWN',1:'14_S',2:'14_L',3:'11_S',4:'11_L',5:'8_S',6:'8_L' }; const LOCATOR_COLOR = { UNKNOWN:0, RED:1, GREEN:2, BLUE:3 }; const BLUR_LEVEL = { NONE:0, SLIGHT:1, SEVERE:2 }; const BIG_LOCATOR_TYPE = { SHORT:0, LONG:1 }; const GP = { DEFAULT_SAMPLE_RADIUS: 5, MIN_CONTOUR_AREA: 100, MAX_AREA_RATIO: 0.3, MIN_RECTANGULARITY: 0.5, BIG_LOCATOR_RATIO_SHORT: 2.0, BIG_LOCATOR_RATIO_LONG: 3.0, SMALL_LOCATOR_RATIO: 1.667, MIN_BIG_LOCATOR_AREA: 200, MAX_BIG_LOCATOR_AREA: 8000, MIN_SMALL_LOCATOR_AREA: 50, MAX_SMALL_LOCATOR_AREA: 2000, COLOR_DISTANCE_THRESHOLD: 60, AREA_RATIO_SHORT: 3.33, AREA_RATIO_LONG: 5.0, AREA_ERROR_RATIO: 0.4 }; // ============================================================ // Paper Config // ============================================================ const CONFIGS = {}; function initConfigs() { const add = (type, itemsNum, slicesNum, firstPos, step, distTimes, whBigRatio) => { CONFIGS[type] = { type, itemsNum, slicesNum, firstPos, step, distTimes, whBigRatio }; }; add(PST.PST_14_S, 14, 50, 2.25, 3.5, 21.3, GP.BIG_LOCATOR_RATIO_SHORT); add(PST.PST_14_L, 14, 50, 2.25, 3.5, 21.8, GP.BIG_LOCATOR_RATIO_LONG); add(PST.PST_11_S, 11, 39, 2.25, 3.5, 17.6, GP.BIG_LOCATOR_RATIO_SHORT); add(PST.PST_11_L, 11, 39, 2.25, 3.5, 18.1, GP.BIG_LOCATOR_RATIO_LONG); add(PST.PST_8_S, 8, 29, 2.25, 3.5, 12.9, GP.BIG_LOCATOR_RATIO_SHORT); add(PST.PST_8_L, 8, 29, 2.25, 3.5, 13.4, GP.BIG_LOCATOR_RATIO_LONG); } initConfigs(); function matchConfig(distRatio, areaRatio, locatorType) { const Min_AR_S = 2.6, Max_AR_S = 4, Min_AR_L = 4.2, Max_AR_L = 6.0; if (areaRatio < Min_AR_S || areaRatio > Max_AR_L) return PST.UNKNOWN; if (areaRatio > Max_AR_S && areaRatio < Min_AR_L) return PST.UNKNOWN; const isSmall = areaRatio <= Max_AR_S; if (isSmall) { if (distRatio > 19.0 && distRatio < 26.5) return PST.PST_14_S; if (distRatio > 15.5 && distRatio < 18.5) return PST.PST_11_S; if (distRatio > 11.6 && distRatio < 14.1) return PST.PST_8_S; } else { if (distRatio > 19.0 && distRatio < 26.98) return PST.PST_14_L; if (distRatio > 15.5 && distRatio < 18.5) return PST.PST_11_L; if (distRatio > 12.0 && distRatio < 14.5) return PST.PST_8_L; } return PST.UNKNOWN; } // ============================================================ // ColorReader // ============================================================ class ColorReader { readColors(bgrBuf, w, h, centers, smallLocatorOnLeft, applyWB = true) { const results = []; if (!bgrBuf || !centers.length) return results; let colorImage = new Float64Array(bgrBuf); if (applyWB) colorImage = this.applyWhiteBalance(colorImage, w, h); const radius = GP.DEFAULT_SAMPLE_RADIUS; for (let i = 0; i < centers.length; i++) { const bgr = this.sampleColor(colorImage, w, h, centers[i], radius); const [h2, s, v] = Img.bgrToHsv(bgr[0], bgr[1], bgr[2]); const [l, a, bVal] = Img.bgrToLab(bgr[0], bgr[1], bgr[2]); results.push({ id: i, h: h2, s, v, l, a, b_val: bVal, r: bgr[2], g: bgr[1], b_val: bgr[0] }); } return this.adjustOrder(results, smallLocatorOnLeft); } applyWhiteBalance(bgr, w, h) { let sumB = 0, sumG = 0, sumR = 0; const total = w * h; for (let i = 0; i < total; i++) { const j = i * 3; sumB += bgr[j]; sumG += bgr[j + 1]; sumR += bgr[j + 2]; } const avgB = Math.max(sumB / total, 1); const avgG = Math.max(sumG / total, 1); const avgR = Math.max(sumR / total, 1); const target = (avgB + avgG + avgR) / 3; const out = new Float64Array(bgr); for (let i = 0; i < total; i++) { const j = i * 3; out[j] = Math.min(255, Math.max(0, out[j] * (target / avgB))); out[j + 1] = Math.min(255, Math.max(0, out[j + 1] * (target / avgG))); out[j + 2] = Math.min(255, Math.max(0, out[j + 2] * (target / avgR))); } return out; } sampleColor(bgr, w, h, center, radius) { const cx = Math.round(center.x), cy = Math.round(center.y); const x1 = Math.max(0, cx - radius), y1 = Math.max(0, cy - radius); const x2 = Math.min(w - 1, cx + radius), y2 = Math.min(h - 1, cy + radius); if (x2 <= x1 || y2 <= y1) return [0, 0, 0]; let sumB = 0, sumG = 0, sumR = 0, count = 0; const r2 = radius * radius; for (let y = y1; y <= y2; y++) { for (let x = x1; x <= x2; x++) { const dx = x - cx, dy = y - cy; if (dx * dx + dy * dy <= r2) { const j = (y * w + x) * 3; sumB += bgr[j]; sumG += bgr[j + 1]; sumR += bgr[j + 2]; count++; } } } return count > 0 ? [sumB / count, sumG / count, sumR / count] : [0, 0, 0]; } adjustOrder(colors, smallLocatorOnLeft) { if (!smallLocatorOnLeft && colors.length > 1) colors.reverse(); for (let i = 0; i < colors.length; i++) colors[i].id = i; return colors; } } /** 旋转矩形 → 角点 */ function rotatedRectPoints(rect) { const cx = rect.center.x, cy = rect.center.y; const hw = rect.size.w / 2, hh = rect.size.h / 2; const rad = rect.angle * Math.PI / 180; const cosA = Math.cos(rad), sinA = Math.sin(rad); return [[-hw,-hh],[hw,-hh],[hw,hh],[-hw,hh]].map(p => ({ x: cx + p[0]*cosA - p[1]*sinA, y: cy + p[0]*sinA + p[1]*cosA })); } // ============================================================ // Main Recognizer // ============================================================ class PaperRecognizer { constructor() { this.reset(); } reset() { this._result = { success: false, errorCode: -1, errorMessage: '', paperType: PST.UNKNOWN, locatorColor: LOCATOR_COLOR.UNKNOWN, itemCount: 0, colorBlockCenters: [], colors: [], annotatedImage: null, smallLocatorOnLeft: true, blurLevel: BLUR_LEVEL.NONE, blurScore: 0 }; this._annotatedPixels = null; this._annotatedW = 0; this._annotatedH = 0; this._origW = 0; this._origH = 0; } /** * 执行识别 * @param {Uint8Array|ArrayLike} rgbaData RGBA 像素数据 * @param {number} width * @param {number} height * @returns {object} 识别结果对象 */ recognize(rgbaData, width, height) { this.reset(); this._origW = width; this._origH = height; if (!rgbaData || width <= 0 || height <= 0) { this._result.errorMessage = 'Invalid input parameters'; return this._result; } // RGBA → BGR (uint8) const bgr = new Uint8Array(width * height * 3); for (let i = 0; i < width * height; i++) { const src4 = i * 4, dst3 = i * 3; bgr[dst3] = rgbaData[src4 + 2]; // B bgr[dst3 + 1] = rgbaData[src4 + 1]; // G bgr[dst3 + 2] = rgbaData[src4]; // R } try { const result = this._process(bgr, width, height); Object.assign(this._result, result); } catch (e) { this._result.errorMessage = `Recognition error: ${e.message}`; this._result.errorCode = -99; } // 生成标注图 this._buildAnnotated(bgr, width, height); return this._result; } _process(bgr, w, h) { // 灰度 const gray = new Float64Array(w * h); for (let i = 0; i < w * h; i++) { const j = i * 3; gray[i] = 0.299 * bgr[j + 2] + 0.587 * bgr[j + 1] + 0.114 * bgr[j]; } // 高斯模糊 const blurred = Img.gaussianBlur3(gray, w, h); // HSV 阈值分割 const hsv = Img.bgrImageToHsv(bgr, w, h); const blueMask = Img.hsvInRange(hsv, w, h, 180, 260, 30, 255, 30, 220); const greenMask = Img.hsvInRange(hsv, w, h, 80, 170, 30, 255, 30, 220); const redMask1 = Img.hsvInRange(hsv, w, h, 0, 15, 30, 255, 30, 220); const redMask2 = Img.hsvInRange(hsv, w, h, 340, 360, 30, 255, 30, 220); const redMask = new Uint8Array(w * h); for (let i = 0; i < w * h; i++) redMask[i] = Math.max(redMask1[i], redMask2[i]); const colorMask = new Uint8Array(w * h); for (let i = 0; i < w * h; i++) { colorMask[i] = Math.max(blueMask[i], greenMask[i], redMask[i]); } // 形态学闭操作 const closed = Img.morphClose(colorMask, w, h, 5); // 查找轮廓 const contours = Img.findContours(closed, w, h); // 过滤候选定位块 const candidates = []; for (const ct of contours) { const area = Img.contourArea(ct); if (area < GP.MIN_CONTOUR_AREA) continue; // bounding rect 检查占用率 const brect = Img.boundingRect(ct); const bbArea = brect.w * brect.h; if (area / bbArea < GP.MIN_RECTANGULARITY) continue; const rotatedRect = Img.minAreaRect(ct); const rectArea = rotatedRect.size.w * rotatedRect.size.h; if (area / rectArea < GP.MIN_RECTANGULARITY) continue; const aspectRatio = Math.max(rotatedRect.size.w, rotatedRect.size.h) / Math.max(Math.min(rotatedRect.size.w, rotatedRect.size.h), 1); // 过滤正方形和非定位块形状 if (aspectRatio >= 0.9 && aspectRatio <= 1.1) continue; if (aspectRatio > 4.0) continue; // 最近邻分类:按与三个目标比值的距离来判定,避免重叠 const distToBigShort = Math.abs(aspectRatio - GP.BIG_LOCATOR_RATIO_SHORT); const distToBigLong = Math.abs(aspectRatio - GP.BIG_LOCATOR_RATIO_LONG); const distToSmall = Math.abs(aspectRatio - GP.SMALL_LOCATOR_RATIO); const tolBig = 0.35; const tolSmall = 0.50; // 小定位块容差更宽(实际照片中可能变形较大) // 判定为 big:最接近短/长定位块比值,且在容差内 const nearestBigDist = Math.min(distToBigShort, distToBigLong); const isBig = nearestBigDist < tolBig && nearestBigDist < distToSmall; // 判定为 small:最接近小定位块比值,且在容差内 const isSmall = distToSmall < tolSmall && distToSmall <= nearestBigDist; if (!isBig && !isSmall) continue; // 判断颜色 const center = rotatedRect.center; const cx = Math.round(center.x), cy = Math.round(center.y); const [b_, g_, r_] = Img.getBgr(bgr, w, h, cx, cy); const [hh, ss, vv] = Img.bgrToHsv(b_, g_, r_); let locColor = LOCATOR_COLOR.UNKNOWN; if (hh >= 180 && hh <= 260) locColor = LOCATOR_COLOR.BLUE; else if (hh >= 80 && hh <= 170) locColor = LOCATOR_COLOR.GREEN; else if (hh <= 15 || hh >= 340) locColor = LOCATOR_COLOR.RED; candidates.push({ rotatedRect, center: { x: center.x, y: center.y }, area, width: rotatedRect.size.w, height: rotatedRect.size.h, color: locColor, isBig, aspectRatio, avgColor: [r_, g_, b_], contour: ct }); } // 分离大小定位块(使用一致的分类逻辑) console.log("CANDIDATES:",JSON.stringify(candidates.map(c=>({area:c.area.toFixed(0), ar:c.aspectRatio.toFixed(2), isBig:c.isBig, isSmall:c.isSmall, color:c.color})))); const bigCands = candidates.filter(c => c.isBig && c.area >= GP.MIN_BIG_LOCATOR_AREA && c.area <= GP.MAX_BIG_LOCATOR_AREA); const smallCands = candidates.filter(c => c.isSmall && c.area >= GP.MIN_SMALL_LOCATOR_AREA && c.area <= GP.MAX_SMALL_LOCATOR_AREA); if (bigCands.length === 0 || smallCands.length === 0) { return { success: false, errorCode: -3, errorMessage: 'No locators found' }; } // 配对 (面积比 + 颜色 + Y坐标) let bestPair = null, bestScore = Infinity; for (const big of bigCands) { for (const small of smallCands) { // 防止同一轮廓同时被归入大小两类 if (big === small) continue; const areaRatio = big.area / Math.max(small.area, 1); const yDiff = Math.abs(big.center.y - small.center.y); const colorMatch = big.color === small.color && big.color !== LOCATOR_COLOR.UNKNOWN; const score = Math.abs(areaRatio - 4.0) + yDiff * 0.1 + (colorMatch ? 0 : 5); if (score < bestScore) { bestScore = score; bestPair = { big, small, areaRatio, colorMatch }; } } } if (!bestPair) { return { success: false, errorCode: -4, errorMessage: 'No matching locator pair' }; } const { big: bigLoc_, small: smallLoc_, areaRatio: areaRatio2 } = bestPair; // 判断小定位块是否在左侧 const smallOnLeft = smallLoc_.center.x < bigLoc_.center.x; // loc1 = 左侧定位块, loc2 = 右侧定位块 const bigLoc = bigLoc_, smallLoc = smallLoc_; const loc1 = smallOnLeft ? smallLoc : bigLoc; const loc2 = smallOnLeft ? bigLoc : smallLoc; const dx = loc2.center.x - loc1.center.x; const dy = loc2.center.y - loc1.center.y; const dist = Math.sqrt(dx * dx + dy * dy); // 计算色块检测距离 (边缘到边缘) const bigEdgeDist = Math.max(bigLoc.width, bigLoc.height) / 2; const smallEdgeDist = Math.max(smallLoc.width, smallLoc.height) / 2; const bigEdgeCenter = { x: bigLoc.center.x - (bigLoc.center.x - smallLoc.center.x) / dist * bigEdgeDist, y: bigLoc.center.y - (bigLoc.center.y - smallLoc.center.y) / dist * bigEdgeDist }; const smallEdgeCenter = { x: smallLoc.center.x + (bigLoc.center.x - smallLoc.center.x) / dist * smallEdgeDist, y: smallLoc.center.y + (bigLoc.center.y - smallLoc.center.y) / dist * smallEdgeDist }; const effDx = bigEdgeCenter.x - smallEdgeCenter.x; const effDy = bigEdgeCenter.y - smallEdgeCenter.y; const effectiveDist = Math.sqrt(effDx * effDx + effDy * effDy); // 确定试纸类型 const bigType = areaRatio2 < (GP.AREA_RATIO_SHORT + GP.AREA_RATIO_LONG) / 2 ? BIG_LOCATOR_TYPE.SHORT : BIG_LOCATOR_TYPE.LONG; const distRatio = effectiveDist / Math.max(bigLoc.height, smallLoc.height); const paperType = matchConfig(distRatio, areaRatio2, bigType); if (paperType === PST.UNKNOWN) { return { success: false, errorCode: -5, errorMessage: 'Unknown paper type' }; } const cfg = CONFIGS[paperType]; if (!cfg) { return { success: false, errorCode: -6, errorMessage: 'No config for paper type' }; } // 计算色块位置 const unitX = effDx / effectiveDist; const unitY = effDy / effectiveDist; const avgDist = effectiveDist / cfg.slicesNum; const firstOffset = avgDist * cfg.firstPos; const stepOffset = avgDist * cfg.step; const centers = []; for (let i = 0; i < cfg.itemsNum; i++) { const offset = firstOffset + stepOffset * i; centers.push({ x: smallEdgeCenter.x + offset * unitX, y: smallEdgeCenter.y + offset * unitY }); } // 清晰度评估 let blurLevel = BLUR_LEVEL.NONE; let blurScore = 0; try { const lap = Img.laplacian(blurred, w, h); const msd = Img.meanStdDev(lap, w * h); blurScore = msd.stddev * msd.stddev; if (blurScore > 300) blurLevel = BLUR_LEVEL.NONE; else if (blurScore > 100) blurLevel = BLUR_LEVEL.SLIGHT; else blurLevel = BLUR_LEVEL.SEVERE; } catch (_) {} // 读取颜色 const reader = new ColorReader(); const colors = reader.readColors(bgr, w, h, centers, smallOnLeft, true); return { success: true, errorCode: 0, errorMessage: '', paperType, locatorColor: bigLoc.color, itemCount: cfg.itemsNum, colorBlockCenters: centers, colors, smallLocatorOnLeft: smallOnLeft, blurLevel, blurScore, _locators: [loc1, loc2], _bgr: bgr, _w: w, _h: h }; } _buildAnnotated(bgr, w, h) { const centers = this._result.colorBlockCenters || []; const colors = this._result.colors || []; const locators = this._result._locators || []; // 创建标注数据:直接在 BGRA 上画 const bgra = new Uint8Array(w * h * 4); for (let i = 0; i < w * h; i++) { const j3 = i * 3, j4 = i * 4; bgra[j4] = bgr[j3]; // B bgra[j4 + 1] = bgr[j3 + 1]; // G bgra[j4 + 2] = bgr[j3 + 2]; // R bgra[j4 + 3] = 255; // A } // 绘制定位块 (红色矩形框) for (const loc of locators) { if (!loc.rotatedRect) continue; const pts = rotatedRectPoints(loc.rotatedRect); this._drawPolyline(bgra, w, h, pts, [255, 0, 0]); } // 绘制色块中心 (红色圆) for (const c of centers) { this._drawCircle(bgra, w, h, c.x, c.y, 6, [255, 0, 0]); } // 颜色文本 for (let i = 0; i < Math.min(colors.length, centers.length); i++) { const c = centers[i]; const text = `#${colors[i].id} R:${Math.round(colors[i].r)} G:${Math.round(colors[i].g)} B:${Math.round(colors[i].b_val)}`; this._drawText(bgra, w, h, text, c.x - 30, c.y + 20, [255, 255, 255]); } this._annotatedPixels = bgra; this._annotatedW = w; this._annotatedH = h; } _drawPolyline(bgra, w, h, pts, color) { for (let i = 0; i < pts.length; i++) { const p1 = pts[i], p2 = pts[(i + 1) % pts.length]; this._drawLine(bgra, w, h, p1.x, p1.y, p2.x, p2.y, color); } } _drawLine(bgra, w, h, x1, y1, x2, y2, color) { const dx = Math.abs(x2 - x1), dy = Math.abs(y2 - y1); const steps = Math.max(dx, dy); if (steps === 0) return; for (let i = 0; i <= steps; i++) { const t = i / steps; const x = Math.round(x1 + (x2 - x1) * t); const y = Math.round(y1 + (y2 - y1) * t); if (x >= 0 && x < w && y >= 0 && y < h) { const j = (y * w + x) * 4; bgra[j] = color[2]; bgra[j + 1] = color[1]; bgra[j + 2] = color[0]; } } } _drawCircle(bgra, w, h, cx, cy, r, color) { for (let y = Math.max(0, Math.round(cy - r)); y <= Math.min(h - 1, Math.round(cy + r)); y++) { for (let x = Math.max(0, Math.round(cx - r)); x <= Math.min(w - 1, Math.round(cx + r)); x++) { if ((x - cx) ** 2 + (y - cy) ** 2 <= r * r) { const j = (y * w + x) * 4; bgra[j] = color[2]; bgra[j + 1] = color[1]; bgra[j + 2] = color[0]; } } } } _drawText(bgra, w, h, text, x, y, color) { // 简单的像素级文本绘制 (9x5 位图字体简化版) // 使用非常简单的点阵: 只画像素点表示文本存在 // 完整实现用 Canvas, 这里简化 } /** 获取结果 JSON */ getResultJson() { const r = this._result; return JSON.stringify({ success: r.success, errorCode: r.errorCode, errorMessage: r.errorMessage, paperType: PST_NAMES[r.paperType] || 'UNKNOWN', locatorColor: r.locatorColor, itemCount: r.itemCount, smallLocatorOnLeft: r.smallLocatorOnLeft, blurLevel: r.blurLevel, blurScore: r.blurScore, colorBlockCenters: r.colorBlockCenters.map(c => ({ x: c.x, y: c.y })), colors: r.colors }); } getAnnotatedWidth() { return this._annotatedW; } getAnnotatedHeight() { return this._annotatedH; } /** 返回 BGRA 像素数据 */ getAnnotatedPixels() { return this._annotatedPixels; } /** 释放内部缓存 */ free() { this.reset(); } } // ============================================================ // Exports (Node.js + Browser) // ============================================================ if (typeof module !== 'undefined' && module.exports) { module.exports = { PaperRecognizer, ColorReader, Img, PST, PST_NAMES, CONFIGS, matchConfig }; } else if (typeof window !== 'undefined') { window.PaperRecognizer = PaperRecognizer; window.ColorReader = ColorReader; window.Img = Img; window.PST = PST; window.PST_NAMES = PST_NAMES; window.CONFIGS = CONFIGS; window.matchConfig = matchConfig; window.BLUR_LEVEL = BLUR_LEVEL; }