1037 lines
35 KiB
JavaScript
1037 lines
35 KiB
JavaScript
/**
|
||
* 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.0, Min_AR_L = 4.0, Max_AR_L = 6.0;
|
||
if (areaRatio < Min_AR_S || areaRatio > Max_AR_L) return PST.UNKNOWN;
|
||
const isSmall = areaRatio <= Max_AR_S;
|
||
if (isSmall) {
|
||
if (distRatio >= 18.5 && distRatio <= 26.5) return PST.PST_14_S;
|
||
if (distRatio >= 14.1 && distRatio < 18.5) return PST.PST_11_S;
|
||
if (distRatio >= 11.6 && distRatio < 14.1) return PST.PST_8_S;
|
||
} else {
|
||
if (distRatio >= 18.5 && distRatio <= 26.98) return PST.PST_14_L;
|
||
if (distRatio >= 14.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) {
|
||
// 兜底:两个大块配对(小定位块被误判为大块)
|
||
if (bigCands.length >= 2 && smallCands.length === 0) {
|
||
// 用小面积的 big 当作 small 再尝试
|
||
const sortedBigs = [...bigCands].sort((a, b) => a.area - b.area);
|
||
for (const big of sortedBigs) {
|
||
for (const small of sortedBigs) {
|
||
if (big === small) continue;
|
||
// 只有面积较小的那一个可以充当 small
|
||
if (big.area <= small.area) continue;
|
||
const areaRatio = big.area / Math.max(small.area, 1);
|
||
if (areaRatio < 2.0 || areaRatio > 6.0) continue;
|
||
smallCands.push(small);
|
||
}
|
||
}
|
||
}
|
||
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;
|
||
}
|