const cvPromise = require('@techstark/opencv-js'); const sharp = require('sharp'); let cvInstance = null; async function getCV() { if (!cvInstance) { cvInstance = await cvPromise; } return cvInstance; } /** * Service de validation OpenCV pour vérifier si une image reçue est bien une cible de tir. */ class TargetValidator { /** * Analyse une image et renvoie le diagnostic OpenCV * @param {string|Buffer} imageInput - Chemin du fichier ou Buffer de l'image * @returns {Promise<{ * isValid: boolean, * status: 'VALID' | 'SUSPICIOUS' | 'INVALID', * confidence: number, * ringsCount: number, * bestCenter: { x: number, y: number, radius: number } | null, * details: string * }>} */ static async validateTarget(imageInput) { const cv = await getCV(); let src = null; let gray = null; let blurred = null; let circles = null; try { // 1. Charger et décoder l'image avec Sharp pour obtenir les pixels bruts RGBA const { data, info } = await sharp(imageInput) .ensureAlpha() // 4 canaux RGBA .raw() .toBuffer({ resolveWithObject: true }); const width = info.width; const height = info.height; const minDim = Math.min(width, height); // 2. Créer une Mat OpenCV à partir des pixels RGBA src = new cv.Mat(height, width, cv.CV_8UC4); src.data.set(data); // 3. Conversion en niveaux de gris gray = new cv.Mat(); cv.cvtColor(src, gray, cv.COLOR_RGBA2GRAY); // 4. Flou Gaussien pour réduire le bruit blurred = new cv.Mat(); const ksize = new cv.Size(9, 9); cv.GaussianBlur(gray, blurred, ksize, 2, 2, cv.BORDER_DEFAULT); // 5. Détection multi-bandes pour capturer tous les anneaux concentriques // (Puisque minDist dans HoughCircles empêche la détection de cercles concentriques sur un seul passage) const detectedCircles = []; const bands = [ { min: Math.floor(minDim * 0.04), max: Math.floor(minDim * 0.18), p2: 45 }, { min: Math.floor(minDim * 0.18), max: Math.floor(minDim * 0.35), p2: 48 }, { min: Math.floor(minDim * 0.35), max: Math.floor(minDim * 0.55), p2: 50 } ]; circles = new cv.Mat(); for (const band of bands) { cv.HoughCircles( blurred, circles, cv.HOUGH_GRADIENT, 1, Math.max(Math.floor(minDim * 0.05), 10), 100, band.p2, band.min, band.max ); if (circles.cols > 0 && circles.data32F) { for (let i = 0; i < circles.cols; i++) { const x = circles.data32F[i * 3]; const y = circles.data32F[i * 3 + 1]; const r = circles.data32F[i * 3 + 2]; if (!isNaN(x) && !isNaN(y) && !isNaN(r) && r > 0) { detectedCircles.push({ x, y, r }); } } } } // Si aucun cercle dans les bandes, tentative globale tolérante if (detectedCircles.length === 0) { cv.HoughCircles( blurred, circles, cv.HOUGH_GRADIENT, 1, Math.max(Math.floor(minDim * 0.08), 10), 100, 35, Math.floor(minDim * 0.05), Math.floor(minDim * 0.55) ); if (circles.cols > 0 && circles.data32F) { for (let i = 0; i < circles.cols; i++) { const x = circles.data32F[i * 3]; const y = circles.data32F[i * 3 + 1]; const r = circles.data32F[i * 3 + 2]; if (!isNaN(x) && !isNaN(y) && !isNaN(r) && r > 0) { detectedCircles.push({ x, y, r }); } } } } // Si aucun cercle détecté if (detectedCircles.length === 0) { return { isValid: false, status: 'INVALID', confidence: 0.02, ringsCount: 0, bestCenter: null, details: 'Aucun motif circulaire ou cible détecté sur cette photo.' }; } // 6. Clustering des cercles pour trouver les anneaux concentriques const tolerance = minDim * 0.07; // Tolérance de 7% pour le décalage de centre const clusters = []; for (const circle of detectedCircles) { let added = false; for (const cluster of clusters) { // Prendre le centre moyen actuel du cluster const avgClusterX = cluster.reduce((sum, c) => sum + c.x, 0) / cluster.length; const avgClusterY = cluster.reduce((sum, c) => sum + c.y, 0) / cluster.length; const dist = Math.sqrt(Math.pow(circle.x - avgClusterX, 2) + Math.pow(circle.y - avgClusterY, 2)); if (dist < tolerance) { // Vérifier que le rayon n'est pas un doublon exact (< 10px de différence) const isDuplicate = cluster.some(c => Math.abs(c.r - circle.r) < minDim * 0.03); if (!isDuplicate) { cluster.push(circle); } added = true; break; } } if (!added) { clusters.push([circle]); } } // Trier les clusters par nombre de cercles concentriques décroissant clusters.sort((a, b) => b.length - a.length); const bestCluster = clusters[0]; const ringsCount = bestCluster.length; // Calculer le centre moyen du meilleur cluster const avgX = bestCluster.reduce((acc, c) => acc + c.x, 0) / ringsCount; const avgY = bestCluster.reduce((acc, c) => acc + c.y, 0) / ringsCount; const maxR = Math.max(...bestCluster.map(c => c.r)); // 7. Évaluation du statut et de la confiance if (ringsCount >= 3) { return { isValid: true, status: 'VALID', confidence: Math.min(0.92 + (ringsCount - 3) * 0.02, 0.99), ringsCount, bestCenter: { x: avgX, y: avgY, radius: maxR }, details: `Cible certifiée : ${ringsCount} anneaux concentriques identifiés.` }; } else if (ringsCount === 2) { return { isValid: true, status: 'VALID', confidence: 0.85, ringsCount, bestCenter: { x: avgX, y: avgY, radius: maxR }, details: `Cible confirmée : ${ringsCount} anneaux concentriques détectés.` }; } else if (ringsCount === 1) { return { isValid: true, status: 'SUSPICIOUS', confidence: 0.50, ringsCount: 1, bestCenter: { x: avgX, y: avgY, radius: maxR }, details: 'Un seul anneau détecté, confirmation visuelle recommandée.' }; } else { return { isValid: false, status: 'INVALID', confidence: 0.1, ringsCount: 0, bestCenter: null, details: 'Motif de cible non conforme.' }; } } catch (error) { console.error('Erreur TargetValidator OpenCV:', error); return { isValid: false, status: 'INVALID', confidence: 0.0, ringsCount: 0, bestCenter: null, details: `Erreur d'analyse OpenCV: ${error.message}` }; } finally { // 8. Nettoyage mémoire if (src && !src.isDeleted()) src.delete(); if (gray && !gray.isDeleted()) gray.delete(); if (blurred && !blurred.isDeleted()) blurred.delete(); if (circles && !circles.isDeleted()) circles.delete(); } } } module.exports = TargetValidator;