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

1138 lines
38 KiB
JavaScript
Raw 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 / 2, 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);
const L = (116 * fy - 16) * 255 / 100;
const A = 500 * (fx - fy) + 128;
const B = 200 * (fy - fz) + 128;
return [L, A, B];
},
/** 高斯模糊 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: 500000,
MIN_SMALL_LOCATOR_AREA: 50,
MAX_SMALL_LOCATOR_AREA: 500000,
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, 18.0, GP.BIG_LOCATOR_RATIO_SHORT);
add(PST.PST_14_L, 14, 50, 2.25, 3.5, 18.5, GP.BIG_LOCATOR_RATIO_LONG);
add(PST.PST_11_S, 11, 39, 2.25, 3.5, 14.5, GP.BIG_LOCATOR_RATIO_SHORT);
add(PST.PST_11_L, 11, 39, 2.25, 3.5, 15.0, GP.BIG_LOCATOR_RATIO_LONG);
add(PST.PST_8_S, 8, 29, 2.25, 3.5, 11.5, GP.BIG_LOCATOR_RATIO_SHORT);
add(PST.PST_8_L, 8, 29, 2.25, 3.5, 12.0, GP.BIG_LOCATOR_RATIO_LONG);
}
initConfigs();
function matchConfig(distRatio, areaRatio, locatorType) {
// 放宽面积比范围
const Min_AR = 1.8, Max_AR = 7.0;
if (areaRatio < Min_AR || areaRatio > Max_AR) return PST.UNKNOWN;
// 所有类型及其 distRatio 中心值和半宽
const types = [
{ pst: PST.PST_14_S, center: 18.0, half: 8.0 },
{ pst: PST.PST_14_L, center: 18.5, half: 8.0 },
{ pst: PST.PST_11_S, center: 14.5, half: 5.0 },
{ pst: PST.PST_11_L, center: 15.0, half: 5.0 },
{ pst: PST.PST_8_S, center: 11.5, half: 4.0 },
{ pst: PST.PST_8_L, center: 12.0, half: 4.0 },
];
// 第一步:找 distRatio 最近匹配
let best = PST.UNKNOWN, bestDist = Infinity;
for (const t of types) {
const d = Math.abs(distRatio - t.center);
if (d <= t.half && d < bestDist) {
bestDist = d;
best = t.pst;
}
}
// 第二步:如果 S/L 不匹配,检查配对类型的距离是否也很近(1.5 以内)
if (best !== PST.UNKNOWN) {
const areaPrefersL = areaRatio > 5.5;
const isBestL = best % 2 === 0;
if (areaPrefersL !== isBestL) {
const altPst = isBestL ? (best - 1) : (best + 1);
if (altPst >= PST.PST_14_S && altPst <= PST.PST_8_L) {
const altType = types.find(t => t.pst === altPst);
if (altType) {
const altDist = Math.abs(distRatio - altType.center);
if (altDist <= altType.half && altDist - bestDist < 1.5) {
best = altPst;
}
}
}
}
}
// 第三步:仍未知则尝试最近邻(忽略 half 限制)
if (best === PST.UNKNOWN) {
for (const t of types) {
const d = Math.abs(distRatio - t.center);
if (d < bestDist) { bestDist = d; best = t.pst; }
}
}
return best;
}
// ============================================================
// 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 imgMinDim = Math.min(w, h);
// 主处理流程
let result = this._processImpl(bgr, w, h, {
minContourArea: GP.MIN_CONTOUR_AREA,
morphK: imgMinDim < 80 ? 3 : 5
});
// 窄图片宽松重试
if (!result.success && imgMinDim < 80) {
result = this._processImpl(bgr, w, h, {
minContourArea: Math.max(30, Math.floor(GP.MIN_CONTOUR_AREA / 2)),
morphK: 1
});
}
return result;
}
_processImpl(bgr, w, h, params) {
const imgMinDim = Math.min(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, 90, 130, 60, 255, 40, 220);
const greenMask = Img.hsvInRange(hsv, w, h, 40, 85, 30, 255, 30, 220);
const redMask1 = Img.hsvInRange(hsv, w, h, 0, 7, 30, 255, 30, 220);
const redMask2 = Img.hsvInRange(hsv, w, h, 170, 180, 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, params.morphK);
// 查找轮廓
const contours = Img.findContours(closed, w, h);
// 过滤候选定位块
const candidates = [];
for (const ct of contours) {
const area = Img.contourArea(ct);
if (area < params.minContourArea) 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) {
const rectHeight = Math.min(rotatedRect.size.w, rotatedRect.size.h);
if (imgMinDim >= 100 || rectHeight < imgMinDim * 0.65) continue;
}
// 放宽长宽比上限以适应窄图
const arMax = imgMinDim < 80 ? 6.0 : 4.0;
if (aspectRatio > arMax) 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 = imgMinDim < 100 ? 0.55 : 0.35;
const tolSmall = imgMinDim < 100 ? 0.70 : 0.50;
const nearestBigDist = Math.min(distToBigShort, distToBigLong);
const isBig = nearestBigDist < tolBig && nearestBigDist < distToSmall;
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 >= 90 && hh <= 130) locColor = LOCATOR_COLOR.BLUE;
else if (hh >= 40 && hh <= 85) locColor = LOCATOR_COLOR.GREEN;
else if (hh <= 7 || hh >= 170) 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
});
}
// 窄图片去噪:保留外围候选,过滤相邻很近的中间噪声
if (imgMinDim < 100 && candidates.length > 2) {
const sorted = [...candidates].sort((a, b) => a.center.x - b.center.x);
const marginPx = w * 0.08;
const filtered = [sorted[0]]; // 保留最左
for (let i = 1; i < sorted.length - 1; i++) {
const c = sorted[i];
// 如果靠近边缘,保留
if (c.center.x < marginPx || w - c.center.x < marginPx) {
filtered.push(c);
}
}
filtered.push(sorted[sorted.length - 1]); // 保留最右
if (filtered.length >= 2) {
candidates.length = 0;
candidates.push(...filtered);
}
}
// 分离大小定位块(使用一致的分类逻辑)
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) {
// 兜底策略1:两个大块配对(小定位块被误判为大块)
if (bigCands.length >= 2 && smallCands.length === 0) {
const sortedBigs = [...bigCands].sort((a, b) => a.area - b.area);
for (const big of sortedBigs) {
for (const small of sortedBigs) {
if (big === small) continue;
if (big.area <= small.area) continue;
const areaRatio = big.area / Math.max(small.area, 1);
if (areaRatio < 1.8 || areaRatio > 7.0) continue;
smallCands.push(small);
}
}
}
// 兜底策略2:两个小块配对(大定位块被误判为小块)
if (bigCands.length === 0 && smallCands.length >= 2) {
const sortedSmalls = [...smallCands].sort((a, b) => b.area - a.area);
if (sortedSmalls.length >= 2 && sortedSmalls[0].area > sortedSmalls[1].area * 1.3) {
bigCands.push(sortedSmalls[0]);
}
}
// 兜底策略3:放宽大小分类,使用松弛的面积限制重试
if ((bigCands.length === 0 || smallCands.length === 0) && candidates.length >= 2) {
for (const c of candidates) {
if (bigCands.length === 0 && c.area >= GP.MIN_BIG_LOCATOR_AREA) {
bigCands.push(c);
} else if (smallCands.length === 0 && c.area >= GP.MIN_SMALL_LOCATOR_AREA) {
smallCands.push(c);
}
}
}
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 invAreaRatio = small.area / Math.max(big.area, 1);
const yDiff = Math.abs(big.center.y - small.center.y);
const colorMatch = big.color === small.color && big.color !== LOCATOR_COLOR.UNKNOWN;
// 取areaRatio和invAreaRatio中最接近4.0的
const arBest = Math.min(Math.abs(areaRatio - 4.0), Math.abs(invAreaRatio - 4.0));
const score = arBest + yDiff * 0.1 + (colorMatch ? 0 : 5);
if (score < bestScore) {
bestScore = score;
const useInv = Math.abs(invAreaRatio - 4.0) < Math.abs(areaRatio - 4.0);
bestPair = { big, small, areaRatio: useInv ? invAreaRatio : 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;
}