OpenCV.js 等比例模板匹配:Canvas 框选 + 局部搜索

问题场景

图 A(已标注,尺寸 W1×H1)在图 B(待匹配,尺寸 W2×H2)中找对应区域。如果 W1≠W2,直接用原始模板做 matchTemplate 会因为尺寸不匹配找错位置。

核心函数

/**
 * 等比例自适应模板匹配
 * @param {HTMLCanvasElement} canvasA - 模板来源图(图A)
 * @param {HTMLCanvasElement} canvasB - 待搜索图(图B)
 * @param {{x,y,w,h}} cropRect      - 图A中框选的区域
 * @param {number} padding           - 搜索区域外扩像素(默认150)
 */
function matchCanvasRegion(canvasA, canvasB, cropRect, padding = 150) {
    let matA = cv.imread(canvasA);
    let matB = cv.imread(canvasB);

    // 计算两图的尺寸比例
    const scale = ((matB.cols / matA.cols) + (matB.rows / matA.rows)) / 2;

    // 从图A切出模板,并按比例缩放
    let rawTempl = matA.roi(new cv.Rect(
        Math.round(cropRect.x), Math.round(cropRect.y),
        Math.round(cropRect.w), Math.round(cropRect.h)
    ));
    let templ = new cv.Mat();
    cv.resize(rawTempl, templ,
        new cv.Size(
            Math.max(1, Math.round(cropRect.w * scale)),
            Math.max(1, Math.round(cropRect.h * scale))
        ), 0, 0, cv.INTER_LINEAR
    );
    rawTempl.delete();

    // 在图B中计算模板的理论位置,加 padding 限定搜索 ROI
    const mappedX = cropRect.x * scale;
    const mappedY = cropRect.y * scale;
    const roiX = Math.max(0, Math.round(mappedX - padding));
    const roiY = Math.max(0, Math.round(mappedY - padding));
    const roiW = Math.min(matB.cols - roiX, templ.cols + padding * 2);
    const roiH = Math.min(matB.rows - roiY, templ.rows + padding * 2);

    let roi = matB.roi(new cv.Rect(roiX, roiY, roiW, roiH));

    // 模板匹配(TM_SQDIFF_NORMED 取最小值)
    let resultMat = new cv.Mat();
    let mask = new cv.Mat();
    cv.matchTemplate(roi, templ, resultMat, cv.TM_SQDIFF_NORMED, mask);
    const result = cv.minMaxLoc(resultMat);

    // 局部坐标转全图坐标
    const finalX = result.minLoc.x + roiX;
    const finalY = result.minLoc.y + roiY;

    // 清理 Mat,避免内存泄漏
    roi.delete(); resultMat.delete(); mask.delete();
    templ.delete(); matA.delete(); matB.delete();

    return {
        x: finalX, y: finalY,
        width: templ.cols, height: templ.rows,
        score: result.minVal,  // 越小越匹配
        scale
    };
}

Canvas 框选实现

let isDragging = false;
let startX, startY;

canvas.addEventListener("mousedown", e => {
    isDragging = true;
    const rect = canvas.getBoundingClientRect();
    startX = e.clientX - rect.left;
    startY = e.clientY - rect.top;
});

canvas.addEventListener("mousemove", e => {
    if (!isDragging) return;
    const rect = canvas.getBoundingClientRect();
    const curX = e.clientX - rect.left;
    const curY = e.clientY - rect.top;
    // 绘制选择框预览
    drawSelectionBox(startX, startY, curX - startX, curY - startY);
});

canvas.addEventListener("mouseup", e => {
    isDragging = false;
    const rect = canvas.getBoundingClientRect();
    const endX = e.clientX - rect.left;
    const endY = e.clientY - rect.top;
    cropRect = {
        x: Math.min(startX, endX),
        y: Math.min(startY, endY),
        w: Math.abs(endX - startX),
        h: Math.abs(endY - startY)
    };
});

canvas 显示尺寸和图像实际尺寸可能不同(canvas.width vs CSS 宽度),传给 matchCanvasRegion 前需换算:

const scaleX = canvas.width  / canvas.offsetWidth;
const scaleY = canvas.height / canvas.offsetHeight;
const realCrop = {
    x: cropRect.x * scaleX,
    y: cropRect.y * scaleY,
    w: cropRect.w * scaleX,
    h: cropRect.h * scaleY
};

关键设计点

为什么用 TM_SQDIFF_NORMED:该方法返回差异值(越小越匹配),对光照变化比 TM_CCOEFF_NORMED 稍宽容,且结果在 0~1 之间便于设置阈值。

为什么限定 ROI 搜索:全图 matchTemplate 在高分辨率图上耗时长。图 A 和图 B 通常是同一场景的不同分辨率版本,目标位置按比例平移,padding 150px 足以覆盖拍摄角度差异。

内存管理:OpenCV.js 是 WebAssembly,new cv.Mat() 分配的内存不受 JS GC 管理,每次使用后必须手动 .delete(),否则会逐渐 OOM。