- Backend IA : - Intégration d'OpenCV (WebAssembly) pour la détection et certification des cibles (anneaux concentriques, confiance). - Ajout du système de journalisation complet des uploads (SQLite, IP, appareil, statut, métadonnées). - Implémentation du système de modération (bannissement/débannissement de wallets, rejet 403 avec motifs). - Harmonisation des codes d'erreur et messages JSON (UPLOAD_SUCCESS, WALLET_BANNED, etc.). - Dashboard Next.js : page dédiée aux logs avec filtres, export CSV et actions de modération en un clic. - Application Mobile (Flutter) : - Encapsulation des réponses d'export dans AiExportResult avec gestion fine des erreurs et statuts. - Mise à jour du disclaimer de participation à l'entraînement IA avec avertissements stricts de bannissement. - Révocation de l'envoi de photos et affichage d'un bandeau explicatif en cas de suspension. - Ajout d'un bouton manuel « Actualiser mon statut » dans les paramètres pour synchroniser l'état du compte. - Possibilité de configurer l'URL/IP du serveur IA directement depuis les paramètres.
231 lines
7.3 KiB
JavaScript
231 lines
7.3 KiB
JavaScript
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;
|