UNPKG

@salama/image-finder

Version:

Advanced template matching tool with OpenCV.js featuring color sensitivity, batch processing, and performance optimization

698 lines (572 loc) 27.3 kB
const fs = require('fs').promises; const path = require('path'); const sharp = require('sharp'); let cv = null; class TemplateMatcher { constructor(options = {}) { this.threshold = options.threshold || 0.85; this.multiscale = options.multiscale || false; this.scales = options.scales || [0.8, 0.85, 0.9, 0.95, 1.0, 1.05, 1.1, 1.15, 1.2,2,3]; this.grayscale = options.grayscale !== false; this.useEdges = options.useEdges || false; this.useBlur = options.useBlur || false; this.useInvert = options.useInvert || true; this.useChannel = options.useChannel || null; this.annotationColor = options.annotationColor || { r: 33, g: 255, b: 0, a: 255 }; this.lineThickness = options.lineThickness || 4; this.method = options.method || null; this.maxMatches = options.maxMatches || 10; // New checkerboard pattern options this.useCheckerboard = options.useCheckerboard || false; this.checkerboardSize = options.checkerboardSize || 20; // Minimum area size to apply pattern this.checkerboardCellSize = options.checkerboardCellSize || 4; // Size of each checker cell this.solidColorThreshold = options.solidColorThreshold || 10; // Color variance threshold for "solid" detection this.cvReady = false; this.cv = null; } async ensureOpenCVReady() { if (!cv) { const cvLoader = require('@techstark/opencv-js'); if (typeof cvLoader === 'function' || (typeof cvLoader === 'object' && cvLoader.then)) { cv = await cvLoader; } else { cv = cvLoader; if (cv.onRuntimeInitialized && !cv.Mat) { await new Promise((resolve) => { cv.onRuntimeInitialized = () => resolve(); }); } } if (!cv || !cv.Mat) throw new Error('Failed to initialize OpenCV.js'); } this.cv = cv; this.cvReady = true; } setMethodByName(methodName) { if (!this.cvReady || !this.cv) { throw new Error('OpenCV must be initialized before setting method'); } const methodMap = { 'CCOEFF': this.cv.TM_CCOEFF, 'CCOEFF_NORMED': this.cv.TM_CCOEFF_NORMED, 'CCORR': this.cv.TM_CCORR, 'CCORR_NORMED': this.cv.TM_CCORR_NORMED, 'SQDIFF': this.cv.TM_SQDIFF, 'SQDIFF_NORMED': this.cv.TM_SQDIFF_NORMED }; if (methodMap[methodName]) { this.method = methodMap[methodName]; return true; } else { console.error(`Invalid method: ${methodName}. Available methods: ${Object.keys(methodMap).join(', ')}`); return false; } } async loadImageFromPath(imagePath) { const image = sharp(imagePath); const { data, info } = await image.ensureAlpha().raw().toBuffer({ resolveWithObject: true }); const mat = new cv.Mat(info.height, info.width, cv.CV_8UC4); mat.data.set(data); return mat; } // New method: Detect solid color regions detectSolidRegions(img) { const gray = new this.cv.Mat(); if (img.channels() > 1) { this.cv.cvtColor(img, gray, this.cv.COLOR_RGBA2GRAY); } else { gray = img.clone(); } // Apply Gaussian blur to reduce noise const blurred = new this.cv.Mat(); this.cv.GaussianBlur(gray, blurred, new this.cv.Size(5, 5), 0); // Calculate local standard deviation to find uniform regions const mean = new this.cv.Mat(); const sqMean = new this.cv.Mat(); const variance = new this.cv.Mat(); const stdDev = new this.cv.Mat(); // Calculate mean and variance in local neighborhoods const kernelSize = new this.cv.Size(this.checkerboardSize, this.checkerboardSize); this.cv.blur(blurred, mean, kernelSize); const blurredSq = new this.cv.Mat(); this.cv.multiply(blurred, blurred, blurredSq); this.cv.blur(blurredSq, sqMean, kernelSize); const meanSq = new this.cv.Mat(); this.cv.multiply(mean, mean, meanSq); this.cv.subtract(sqMean, meanSq, variance); this.cv.sqrt(variance, stdDev); meanSq.delete(); // Create mask for solid regions (low standard deviation) const solidMask = new this.cv.Mat(); this.cv.threshold(stdDev, solidMask, this.solidColorThreshold, 255, this.cv.THRESH_BINARY_INV); // Clean up gray.delete(); blurred.delete(); blurredSq.delete(); mean.delete(); sqMean.delete(); variance.delete(); stdDev.delete(); return solidMask; } // New method: Apply checkerboard pattern to solid regions applyCheckerboardPattern(img) { const result = img.clone(); const solidMask = this.detectSolidRegions(img); // Create checkerboard pattern using rectangle drawing (safer approach) const checkerboard = new this.cv.Mat.zeros(img.rows, img.cols, this.cv.CV_8UC1); const cellSize = this.checkerboardCellSize; for (let y = 0; y < img.rows; y += cellSize) { for (let x = 0; x < img.cols; x += cellSize) { const cellX = Math.floor(x / cellSize); const cellY = Math.floor(y / cellSize); const isWhite = (cellX + cellY) % 2 === 0; if (isWhite) { const pt1 = new this.cv.Point(x, y); const pt2 = new this.cv.Point( Math.min(x + cellSize, img.cols), Math.min(y + cellSize, img.rows) ); this.cv.rectangle(checkerboard, pt1, pt2, new this.cv.Scalar(255), -1); } } } // Convert to floating point for calculations const resultFloat = new this.cv.Mat(); result.convertTo(resultFloat, this.cv.CV_32F); const checkerFloat = new this.cv.Mat(); checkerboard.convertTo(checkerFloat, this.cv.CV_32F, 1.0/255.0); // Create intensity multiplier (1.1 for white, 0.9 for black) const intensityMult = new this.cv.Mat(); this.cv.addWeighted(checkerFloat, 0.2, checkerFloat, 0.0, 0.9, intensityMult); // Apply pattern based on solid mask const solidFloat = new this.cv.Mat(); solidMask.convertTo(solidFloat, this.cv.CV_32F, 1.0/255.0); const channels = result.channels(); if (channels === 1) { // Grayscale: multiply by intensity where solid const modified = new this.cv.Mat(); this.cv.multiply(resultFloat, intensityMult, modified); // Blend original and modified based on solid mask const finalResult = new this.cv.Mat(); this.cv.addWeighted(resultFloat, 1.0, modified, 0.0, 0.0, finalResult); // Use solidFloat as alpha for blending const invSolid = new this.cv.Mat(); this.cv.subtract(new this.cv.Scalar(1.0), solidFloat, invSolid); const part1 = new this.cv.Mat(); const part2 = new this.cv.Mat(); this.cv.multiply(resultFloat, invSolid, part1); this.cv.multiply(modified, solidFloat, part2); this.cv.add(part1, part2, finalResult); finalResult.convertTo(result, this.cv.CV_8U); modified.delete(); finalResult.delete(); invSolid.delete(); part1.delete(); part2.delete(); } else { // Multi-channel: apply to each RGB channel const channels_vec = new this.cv.MatVector(); this.cv.split(resultFloat, channels_vec); const numColorChannels = Math.min(3, channels); for (let c = 0; c < numColorChannels; c++) { const channel = channels_vec.get(c); const modified = new this.cv.Mat(); this.cv.multiply(channel, intensityMult, modified); // Blend original and modified based on solid mask const invSolid = new this.cv.Mat(); this.cv.subtract(new this.cv.Scalar(1.0), solidFloat, invSolid); const part1 = new this.cv.Mat(); const part2 = new this.cv.Mat(); this.cv.multiply(channel, invSolid, part1); this.cv.multiply(modified, solidFloat, part2); const blended = new this.cv.Mat(); this.cv.add(part1, part2, blended); // Replace the channel blended.copyTo(channel); modified.delete(); invSolid.delete(); part1.delete(); part2.delete(); blended.delete(); } // Merge back const merged = new this.cv.Mat(); this.cv.merge(channels_vec, merged); merged.convertTo(result, this.cv.CV_8U); // Cleanup for (let c = 0; c < channels_vec.size(); c++) { channels_vec.get(c).delete(); } channels_vec.delete(); merged.delete(); } // Cleanup checkerboard.delete(); resultFloat.delete(); checkerFloat.delete(); intensityMult.delete(); solidFloat.delete(); solidMask.delete(); return result; } // Enhanced preprocessing with checkerboard pattern preprocessImage(img) { let processed = img.clone(); // Apply checkerboard pattern to solid regions first if (this.useCheckerboard) { const withPattern = this.applyCheckerboardPattern(processed); processed.delete(); processed = withPattern; } // Convert to grayscale if enabled if (this.grayscale && processed.channels() > 1) { const gray = new this.cv.Mat(); this.cv.cvtColor(processed, gray, this.cv.COLOR_RGBA2GRAY); processed.delete(); processed = gray; } // Apply Gaussian blur if enabled if (this.useBlur) { const blurred = new this.cv.Mat(); this.cv.GaussianBlur(processed, blurred, new this.cv.Size(3, 3), 0); processed.delete(); processed = blurred; } // Apply Canny edge detection if enabled if (this.useEdges) { const edges = new this.cv.Mat(); this.cv.Canny(processed, edges, 50, 150); processed.delete(); processed = edges; } return processed; } extractBestMatch(result, templateWidth, templateHeight, method) { const isMinMethod = method === this.cv.TM_SQDIFF || method === this.cv.TM_SQDIFF_NORMED; const matches = []; const resultCopy = result.clone(); for (let i = 0; i < this.maxMatches; i++) { const minMax = this.cv.minMaxLoc(resultCopy); const confidence = isMinMethod ? minMax.minVal : minMax.maxVal; const loc = isMinMethod ? minMax.minLoc : minMax.maxLoc; const matchPass = (!isMinMethod && confidence >= this.threshold) || (isMinMethod && confidence <= this.threshold); if (matchPass) { matches.push({ x: loc.x, y: loc.y, width: templateWidth, height: templateHeight, confidence: this.normalizeConfidence(confidence, method), rawConfidence: confidence, scale: 1.0, method: method }); const suppressValue = isMinMethod ? 1.0 : 0.0; const suppressSize = Math.max(templateWidth, templateHeight) / 3; const pt1 = new this.cv.Point( Math.max(0, loc.x - suppressSize), Math.max(0, loc.y - suppressSize) ); const pt2 = new this.cv.Point( Math.min(resultCopy.cols, loc.x + templateWidth + suppressSize), Math.min(resultCopy.rows, loc.y + templateHeight + suppressSize) ); const suppressColor = new this.cv.Scalar(suppressValue); this.cv.rectangle(resultCopy, pt1, pt2, suppressColor, -1); } else { break; } } resultCopy.delete(); return matches; } normalizeConfidence(rawConfidence, method) { const isMinMethod = method === this.cv.TM_SQDIFF || method === this.cv.TM_SQDIFF_NORMED; const isNormalized = method === this.cv.TM_CCOEFF_NORMED || method === this.cv.TM_SQDIFF_NORMED || method === this.cv.TM_CCORR_NORMED; if (isNormalized) { return isMinMethod ? (1.0 - rawConfidence) : rawConfidence; } else { if (isMinMethod) { return Math.max(0, 1.0 - Math.min(1.0, rawConfidence / 100000)); } else { return Math.min(1.0, Math.max(0, rawConfidence / 100000)); } } } async singleScaleMatch(screenshot, template) { const processedScreenshot = this.preprocessImage(screenshot); const processedTemplate = this.preprocessImage(template); const result = new this.cv.Mat(); const mask = new this.cv.Mat(); const methods = this.method ? [this.method] : [this.cv.TM_CCOEFF_NORMED, this.cv.TM_SQDIFF_NORMED]; let allMatches = []; for (const method of methods) { this.cv.matchTemplate(processedScreenshot, processedTemplate, result, method, mask); const matches = this.extractBestMatch(result, template.cols, template.rows, method); allMatches = allMatches.concat(matches); } allMatches.sort((a, b) => b.confidence - a.confidence); const nmsMatches = this.applyNonMaxSuppression(allMatches); processedScreenshot.delete(); processedTemplate.delete(); result.delete(); mask.delete(); return nmsMatches.slice(0, this.maxMatches); } async multiScaleMatch(screenshot, template) { let allMatches = []; for (const scale of this.scales) { const scaledTemplate = new this.cv.Mat(); const size = new this.cv.Size(Math.round(template.cols * scale), Math.round(template.rows * scale)); this.cv.resize(template, scaledTemplate, size, 0, 0, this.cv.INTER_LINEAR); const matches = await this.singleScaleMatch(screenshot, scaledTemplate); matches.forEach(match => { match.scale = scale; match.x = Math.round(match.x); match.y = Math.round(match.y); match.width = Math.round(template.cols * scale); match.height = Math.round(template.rows * scale); }); allMatches = allMatches.concat(matches); scaledTemplate.delete(); } const nmsMatches = this.applyNonMaxSuppression(allMatches, 0.3); return nmsMatches.slice(0, this.maxMatches); } applyNonMaxSuppression(matches, overlapThreshold = 0.3) { if (matches.length === 0) return matches; matches.sort((a, b) => b.confidence - a.confidence); const kept = []; const suppressed = new Set(); for (let i = 0; i < matches.length; i++) { if (suppressed.has(i)) continue; const current = matches[i]; kept.push(current); for (let j = i + 1; j < matches.length; j++) { if (suppressed.has(j)) continue; const other = matches[j]; const overlap = this.calculateIoU(current, other); if (overlap > overlapThreshold) { suppressed.add(j); } } } return kept; } calculateIoU(match1, match2) { const x1 = Math.max(match1.x, match2.x); const y1 = Math.max(match1.y, match2.y); const x2 = Math.min(match1.x + match1.width, match2.x + match2.width); const y2 = Math.min(match1.y + match1.height, match2.y + match2.height); if (x2 <= x1 || y2 <= y1) return 0; const intersectionArea = (x2 - x1) * (y2 - y1); const area1 = match1.width * match1.height; const area2 = match2.width * match2.height; const unionArea = area1 + area2 - intersectionArea; return intersectionArea / unionArea; } async refineMatches(screenshot, template, initialMatches) { const refinedMatches = []; for (const match of initialMatches) { const padding = 10; const x = Math.max(0, match.x - padding); const y = Math.max(0, match.y - padding); const width = Math.min(screenshot.cols - x, match.width + 2 * padding); const height = Math.min(screenshot.rows - y, match.height + 2 * padding); const roi = screenshot.roi(new this.cv.Rect(x, y, width, height)); const result = new this.cv.Mat(); this.cv.matchTemplate(roi, template, result, match.method); const minMax = this.cv.minMaxLoc(result); const isMinMethod = match.method === this.cv.TM_SQDIFF || match.method === this.cv.TM_SQDIFF_NORMED; const newConfidence = isMinMethod ? minMax.minVal : minMax.maxVal; const newLoc = isMinMethod ? minMax.minLoc : minMax.maxLoc; refinedMatches.push({ x: x + newLoc.x, y: y + newLoc.y, width: match.width, height: match.height, confidence: this.normalizeConfidence(newConfidence, match.method), rawConfidence: newConfidence, scale: match.scale, method: match.method }); roi.delete(); result.delete(); } return refinedMatches; } async findMatches(screenshotPath, templatePath) { await this.ensureOpenCVReady(); const screenshot = await this.loadImageFromPath(screenshotPath); const template = await this.loadImageFromPath(templatePath); let matches; if (this.multiscale) { matches = await this.multiScaleMatch(screenshot, template); } else { matches = await this.singleScaleMatch(screenshot, template); } const annotatedImage = this.annotateMatches(screenshot, matches); screenshot.delete(); template.delete(); return { matches: matches, annotatedImage: annotatedImage }; } annotateMatches(screenshot, matches) { const annotated = screenshot.clone(); if (annotated.channels() === 1) { this.cv.cvtColor(annotated, annotated, this.cv.COLOR_GRAY2RGBA); } matches.forEach((match, idx) => { const pt1 = new this.cv.Point(Math.round(match.x), Math.round(match.y)); const pt2 = new this.cv.Point(Math.round(match.x + match.width), Math.round(match.y + match.height)); const colors = [ { r: 33, g: 255, b: 0, a: 255 }, // Green { r: 255, g: 33, b: 0, a: 255 }, // Red { r: 0, g: 33, b: 255, a: 255 }, // Blue { r: 255, g: 255, b: 0, a: 255 }, // Yellow { r: 255, g: 0, b: 255, a: 255 }, // Magenta { r: 0, g: 255, b: 255, a: 255 }, // Cyan { r: 255, g: 128, b: 0, a: 255 }, // Orange { r: 128, g: 0, b: 255, a: 255 }, // Purple { r: 255, g: 192, b: 203, a: 255 },// Pink { r: 128, g: 128, b: 128, a: 255 } // Gray ]; const colorIndex = idx % colors.length; const color = new this.cv.Scalar(colors[colorIndex].r, colors[colorIndex].g, colors[colorIndex].b, colors[colorIndex].a); this.cv.rectangle(annotated, pt1, pt2, color, this.lineThickness, this.cv.LINE_8, 0); const label = `#${idx} ${(match.confidence * 100).toFixed(1)}% [${match.method}]`; const textPt = new this.cv.Point(Math.round(match.x), Math.max(0, Math.round(match.y) - 5)); this.cv.putText(annotated, label, textPt, this.cv.FONT_HERSHEY_SIMPLEX, 0.5, color, 1, this.cv.LINE_AA); }); return annotated; } async matToBuffer(mat) { const width = mat.cols; const height = mat.rows; const channels = mat.channels(); let buffer; if (channels === 4) { buffer = Buffer.from(mat.data); } else { buffer = Buffer.allocUnsafe(width * height * 4); for (let i = 0, j = 0; i < mat.data.length; i++, j += 4) { if (channels === 1) { const val = mat.data[i]; buffer[j] = val; buffer[j + 1] = val; buffer[j + 2] = val; } else { buffer[j] = mat.data[i * 3]; buffer[j + 1] = mat.data[i * 3 + 1]; buffer[j + 2] = mat.data[i * 3 + 2]; } buffer[j + 3] = 255; } } return sharp(buffer, { raw: { width, height, channels: 4 } }).png().toBuffer(); } async saveAnnotatedImage(annotatedMat, outputPath) { const buffer = await this.matToBuffer(annotatedMat); await fs.writeFile(outputPath, buffer); } } // Enhanced CLI async function main() { const args = process.argv.slice(2); if (args.length < 2 || args.includes('--help')) { console.log(`Usage: node enhanced-cv.js [options] <screenshot> <template> Options: --threshold <value> Matching threshold (default 0.85) --multiscale Enable multi-scale matching --edges Enable edge-based preprocessing --blur Enable Gaussian blur preprocessing --checkerboard Enable checkerboard pattern for solid regions --checker-size <value> Minimum area size for checkerboard pattern (default 20) --checker-cell <value> Size of each checker cell (default 4) --solid-threshold <value> Color variance threshold for solid detection (default 10) --method <name> Force specific method (CCOEFF, CCOEFF_NORMED, CCORR, CCORR_NORMED, SQDIFF, SQDIFF_NORMED) --output <path> Save annotated image to path Example: node enhanced-cv.js screen.png template.png --threshold 0.8 --multiscale --checkerboard --output result.png `); process.exit(0); } const options = { threshold: 0.85, multiscale: false, grayscale: true, useEdges: false, useBlur: false, useCheckerboard: false, checkerboardSize: 20, checkerboardCellSize: 4, solidColorThreshold: 10 }; const positional = []; for (let i = 0; i < args.length; i++) { const arg = args[i]; if (arg === '--threshold') options.threshold = parseFloat(args[++i]); else if (arg === '--multiscale') options.multiscale = true; else if (arg === '--edges') options.useEdges = true; else if (arg === '--blur') options.useBlur = true; else if (arg === '--checkerboard') options.useCheckerboard = true; else if (arg === '--checker-size') options.checkerboardSize = parseInt(args[++i]); else if (arg === '--checker-cell') options.checkerboardCellSize = parseInt(args[++i]); else if (arg === '--solid-threshold') options.solidColorThreshold = parseInt(args[++i]); else if (arg === '--method') { const methodName = args[++i]; const methodMap = { 'CCOEFF': 'CCOEFF', 'CCOEFF_NORMED': 'CCOEFF_NORMED', 'CCORR': 'CCORR', 'CCORR_NORMED': 'CCORR_NORMED', 'SQDIFF': 'SQDIFF', 'SQDIFF_NORMED': 'SQDIFF_NORMED' }; if (methodMap[methodName]) { options.methodName = methodName; } else { console.error(`Invalid method: ${methodName}. Available methods: ${Object.keys(methodMap).join(', ')}`); process.exit(1); } } else if (arg === '--output') options.output = args[++i]; else positional.push(arg); } const [screenshotPath, templatePath] = positional; if (!screenshotPath || !templatePath) { console.error('Error: Both screenshot and template paths are required'); process.exit(1); } const matcher = new TemplateMatcher(options); // Set method if specified if (options.methodName) { await matcher.ensureOpenCVReady(); matcher.setMethodByName(options.methodName); } try { console.log('Running enhanced template matching with checkerboard pattern...'); const result = await matcher.findMatches(screenshotPath, templatePath); console.log(`Found ${result.matches.length} match(es):`); result.matches.forEach((m, idx) => { console.log(`#${idx}: [${m.x}, ${m.y}] Confidence: ${m.confidence.toFixed(4)} (Raw: ${m.rawConfidence.toFixed(4)}) Scale: ${m.scale}`); }); if (options.output) { await matcher.saveAnnotatedImage(result.annotatedImage, options.output); console.log(`Annotated image saved to: ${options.output}`); } result.annotatedImage.delete(); } catch (err) { console.error('Error:', err); process.exit(1); } } module.exports = TemplateMatcher; if (require.main === module) { main().catch(console.error); }