Files
nanocode/web-opencv/miniappB/paper-recognizer-dbg3.js
Ubuntu 32b43f85bd a
2026-07-03 16:58:28 +08:00

1021 lines
35 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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, isSmall, aspectRatio, avgColor: [r_, g_, b_],
contour: ct
});
}
// 分离大小定位块(使用一致的分类逻辑)
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);
console.log("DEBUG matchConfig: distRatio="+distRatio.toFixed(2)+" areaRatio="+areaRatio2.toFixed(2)+" bigType="+bigType); 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;
}