/// Service de correction de distorsion d'objectif. /// /// Utilise la calibration des cercles de la cible pour calculer et appliquer /// une transformation qui corrige la distorsion de l'objectif. L'image est /// transformée pour que les cercles calibrés deviennent parfaitement circulaires. library; import 'dart:io'; import 'dart:math' as math; import 'package:image/image.dart' as img; import 'package:opencv_dart/opencv_dart.dart' as cv; import 'package:path_provider/path_provider.dart'; /// Paramètres de distorsion calculés à partir de la calibration class DistortionParameters { /// Ratio d'aplatissement horizontal (1.0 = pas de correction) final double scaleX; /// Ratio d'aplatissement vertical (1.0 = pas de correction) final double scaleY; /// Angle de rotation de l'axe principal (en radians) final double rotation; /// Centre de la distorsion en coordonnées normalisées (0-1) final double centerX; final double centerY; /// Ratio de circularité détecté (1.0 = cercle parfait) final double circularityRatio; const DistortionParameters({ required this.scaleX, required this.scaleY, required this.rotation, required this.centerX, required this.centerY, required this.circularityRatio, }); /// Paramètres par défaut (pas de correction) static const identity = DistortionParameters( scaleX: 1.0, scaleY: 1.0, rotation: 0.0, centerX: 0.5, centerY: 0.5, circularityRatio: 1.0, ); /// La correction est-elle nécessaire ? bool get needsCorrection => circularityRatio < 0.95; @override String toString() { return 'DistortionParameters(scaleX: ${scaleX.toStringAsFixed(3)}, ' 'scaleY: ${scaleY.toStringAsFixed(3)}, ' 'rotation: ${(rotation * 180 / math.pi).toStringAsFixed(1)}°, ' 'circularity: ${(circularityRatio * 100).toStringAsFixed(1)}%)'; } } /// Service pour détecter et corriger la distorsion d'objectif class DistortionCorrectionService { /// Calcule les paramètres de distorsion à partir de la calibration des cercles /// /// [targetCenterX], [targetCenterY] : Centre de la cible calibré (0-1) /// [targetRadius] : Rayon de la cible calibré /// [imageAspectRatio] : Ratio largeur/hauteur de l'image /// /// Cette méthode analyse la forme attendue (cercle) vs la forme observée /// pour déterminer les paramètres de correction. DistortionParameters calculateDistortionFromCalibration({ required double targetCenterX, required double targetCenterY, required double targetRadius, required double imageAspectRatio, }) { // En théorie, si la cible est un cercle parfait et que l'utilisateur // a calibré les anneaux pour qu'ils correspondent à ce qu'il voit, // on peut déduire la distorsion. // Pour l'instant, on utilise une approche simplifiée basée sur l'aspect ratio // et la position du centre par rapport au centre de l'image. // Calcul de l'excentricité basée sur la position du centre final offsetX = targetCenterX - 0.5; final offsetY = targetCenterY - 0.5; final offsetDistance = math.sqrt(offsetX * offsetX + offsetY * offsetY); // Plus le centre est éloigné du centre de l'image, plus la distorsion est probable // Estimation simplifiée de la distorsion radiale final distortionFactor = 1.0 + offsetDistance * 0.2; // Calculer l'angle de l'axe principal de déformation final angle = math.atan2(offsetY, offsetX); // Si l'image n'est pas carrée, tenir compte de l'aspect ratio double scaleX = 1.0; double scaleY = 1.0; if (imageAspectRatio > 1.0) { // Image plus large que haute scaleY = distortionFactor; } else if (imageAspectRatio < 1.0) { // Image plus haute que large scaleX = distortionFactor; } final circularityRatio = 1.0 / distortionFactor; return DistortionParameters( scaleX: scaleX, scaleY: scaleY, rotation: angle, centerX: targetCenterX, centerY: targetCenterY, circularityRatio: circularityRatio, ); } /// Calcule les paramètres de distorsion en comparant un cercle théorique /// avec les points de calibration fournis par l'utilisateur /// /// [calibrationPoints] : Points sur l'ellipse visible (coordonnées 0-1) /// [expectedRadius] : Rayon du cercle théorique /// [centerX], [centerY] : Centre du cercle théorique DistortionParameters calculateDistortionFromPoints({ required List<({double x, double y})> calibrationPoints, required double expectedRadius, required double centerX, required double centerY, }) { if (calibrationPoints.length < 4) { return DistortionParameters.identity; } // Calculer les distances de chaque point au centre final distances = []; final angles = []; for (final point in calibrationPoints) { final dx = point.x - centerX; final dy = point.y - centerY; distances.add(math.sqrt(dx * dx + dy * dy)); angles.add(math.atan2(dy, dx)); } // Trouver les axes majeur et mineur de l'ellipse double maxDist = 0, minDist = double.infinity; double maxAngle = 0; for (int i = 0; i < distances.length; i++) { if (distances[i] > maxDist) { maxDist = distances[i]; maxAngle = angles[i]; } if (distances[i] < minDist) { minDist = distances[i]; } } // Calculer les facteurs de correction // On veut que maxDist et minDist deviennent égaux à expectedRadius final scaleX = expectedRadius / maxDist; final scaleY = expectedRadius / minDist; final circularityRatio = minDist / maxDist; return DistortionParameters( scaleX: scaleX.clamp(0.5, 2.0), scaleY: scaleY.clamp(0.5, 2.0), rotation: maxAngle, centerX: centerX, centerY: centerY, circularityRatio: circularityRatio, ); } /// Applique la correction de distorsion à une image /// /// [imagePath] : Chemin de l'image source /// [params] : Paramètres de distorsion calculés /// /// Retourne le chemin de l'image corrigée Future applyCorrection( String imagePath, DistortionParameters params, ) async { final file = File(imagePath); final bytes = await file.readAsBytes(); final image = img.decodeImage(bytes); if (image == null) { throw Exception('Impossible de décoder l\'image'); } final correctedImage = _transformImage(image, params); // Sauvegarder l'image corrigée final tempDir = await getTemporaryDirectory(); final timestamp = DateTime.now().millisecondsSinceEpoch; final outputPath = '${tempDir.path}/corrected_$timestamp.jpg'; final outputFile = File(outputPath); await outputFile.writeAsBytes(img.encodeJpg(correctedImage, quality: 95)); return outputPath; } /// Transforme l'image pour corriger la distorsion img.Image _transformImage(img.Image source, DistortionParameters params) { final width = source.width; final height = source.height; // Créer une nouvelle image de même taille final result = img.Image(width: width, height: height); // Centre de transformation en pixels final cx = params.centerX * width; final cy = params.centerY * height; // Précalculer cos/sin de la rotation final cosR = math.cos(params.rotation); final sinR = math.sin(params.rotation); // Pour chaque pixel de l'image destination, trouver le pixel source correspondant for (int y = 0; y < height; y++) { for (int x = 0; x < width; x++) { // Coordonnées relatives au centre final dx = x - cx; final dy = y - cy; // Rotation inverse final rx = dx * cosR + dy * sinR; final ry = -dx * sinR + dy * cosR; // Mise à l'échelle inverse (pour trouver d'où vient le pixel) final sx = rx / params.scaleX; final sy = ry / params.scaleY; // Rotation dans l'autre sens final fx = sx * cosR - sy * sinR; final fy = sx * sinR + sy * cosR; // Coordonnées source final srcX = fx + cx; final srcY = fy + cy; // Interpolation bilinéaire final pixel = _bilinearInterpolate(source, srcX, srcY); result.setPixel(x, y, pixel); } } return result; } /// Interpolation bilinéaire pour un échantillonnage de qualité img.Color _bilinearInterpolate(img.Image image, double x, double y) { final x0 = x.floor(); final y0 = y.floor(); final x1 = x0 + 1; final y1 = y0 + 1; // Vérifier les limites if (x0 < 0 || y0 < 0 || x1 >= image.width || y1 >= image.height) { // Retourner le pixel le plus proche pour les zones hors limites return image.getPixel( x.round().clamp(0, image.width - 1), y.round().clamp(0, image.height - 1), ); } // Poids pour l'interpolation final wx = x - x0; final wy = y - y0; // Récupérer les 4 pixels voisins final p00 = image.getPixel(x0, y0); final p10 = image.getPixel(x1, y0); final p01 = image.getPixel(x0, y1); final p11 = image.getPixel(x1, y1); // Interpoler chaque canal final r = _lerp2D( p00.r.toDouble(), p10.r.toDouble(), p01.r.toDouble(), p11.r.toDouble(), wx, wy, ); final g = _lerp2D( p00.g.toDouble(), p10.g.toDouble(), p01.g.toDouble(), p11.g.toDouble(), wx, wy, ); final b = _lerp2D( p00.b.toDouble(), p10.b.toDouble(), p01.b.toDouble(), p11.b.toDouble(), wx, wy, ); final a = _lerp2D( p00.a.toDouble(), p10.a.toDouble(), p01.a.toDouble(), p11.a.toDouble(), wx, wy, ); return img.ColorRgba8( r.round().clamp(0, 255), g.round().clamp(0, 255), b.round().clamp(0, 255), a.round().clamp(0, 255), ); } /// Interpolation linéaire 2D double _lerp2D( double v00, double v10, double v01, double v11, double wx, double wy, ) { final top = v00 * (1 - wx) + v10 * wx; final bottom = v01 * (1 - wx) + v11 * wx; return top * (1 - wy) + bottom * wy; } /// Applique une correction de perspective simple basée sur 4 points /// /// Cette méthode est utile quand la photo est prise en angle. /// [corners] : Les 4 coins de la cible dans l'ordre (haut-gauche, haut-droite, bas-droite, bas-gauche) Future applyPerspectiveCorrection( String imagePath, List<({double x, double y})> corners, ) async { if (corners.length != 4) { throw ArgumentError('4 points de coin sont requis'); } final file = File(imagePath); final bytes = await file.readAsBytes(); final image = img.decodeImage(bytes); if (image == null) { throw Exception('Impossible de décoder l\'image'); } final width = image.width; final height = image.height; // Convertir les coordonnées normalisées en pixels final srcCorners = corners .map((c) => (x: c.x * width, y: c.y * height)) .toList(); // Calculer la taille du rectangle destination // On prend la moyenne des largeurs et hauteurs final topWidth = _distance(srcCorners[0], srcCorners[1]); final bottomWidth = _distance(srcCorners[3], srcCorners[2]); final leftHeight = _distance(srcCorners[0], srcCorners[3]); final rightHeight = _distance(srcCorners[1], srcCorners[2]); final dstWidth = ((topWidth + bottomWidth) / 2).round(); final dstHeight = ((leftHeight + rightHeight) / 2).round(); // Créer l'image destination final result = img.Image(width: dstWidth, height: dstHeight); // Calculer la matrice de transformation perspective final matrix = _computePerspectiveMatrix(srcCorners, [ (x: 0.0, y: 0.0), (x: dstWidth.toDouble(), y: 0.0), (x: dstWidth.toDouble(), y: dstHeight.toDouble()), (x: 0.0, y: dstHeight.toDouble()), ]); // Appliquer la transformation for (int y = 0; y < dstHeight; y++) { for (int x = 0; x < dstWidth; x++) { final src = _applyPerspectiveTransform( matrix, x.toDouble(), y.toDouble(), ); if (src.x >= 0 && src.x < width && src.y >= 0 && src.y < height) { final pixel = _bilinearInterpolate(image, src.x, src.y); result.setPixel(x, y, pixel); } } } // Sauvegarder final tempDir = await getTemporaryDirectory(); final timestamp = DateTime.now().millisecondsSinceEpoch; final outputPath = '${tempDir.path}/perspective_$timestamp.jpg'; final outputFile = File(outputPath); await outputFile.writeAsBytes(img.encodeJpg(result, quality: 95)); return outputPath; } double _distance(({double x, double y}) p1, ({double x, double y}) p2) { final dx = p2.x - p1.x; final dy = p2.y - p1.y; return math.sqrt(dx * dx + dy * dy); } /// Calcule la matrice de transformation perspective (homographie) List _computePerspectiveMatrix( List<({double x, double y})> src, List<({double x, double y})> dst, ) { // Résolution du système linéaire pour trouver la matrice 3x3 // Utilisation de la méthode DLT (Direct Linear Transform) final a = List>.generate(8, (_) => List.filled(9, 0.0)); for (int i = 0; i < 4; i++) { final sx = src[i].x; final sy = src[i].y; final dx = dst[i].x; final dy = dst[i].y; a[i * 2] = [-sx, -sy, -1, 0, 0, 0, dx * sx, dx * sy, dx]; a[i * 2 + 1] = [0, 0, 0, -sx, -sy, -1, dy * sx, dy * sy, dy]; } // Résolution par SVD simplifiée (on utilise une approximation) // Pour une implémentation complète, il faudrait une vraie décomposition SVD final h = _solveHomography(a); return h; } /// Résout le système linéaire pour trouver la matrice d'homographie 3x3. /// Utilise l'élimination de Gauss-Jordan avec pivot partiel pour la stabilité. List _solveHomography(List> a) { // Le système 'a' est de taille 8x9 (8 équations, 9 inconnues). // On fixe h8 = 1.0 pour résoudre le système, ce qui nous donne un système 8x8. final int n = 8; final List> matrix = List.generate( n, (i) => List.from(a[i]), ); // Vecteur B (les constantes de l'autre côté de l'égalité) // Dans DLT, -h8 * dx (ou dy) devient le terme constant. final List b = List.generate(n, (i) => -matrix[i][8]); // Élimination de Gauss-Jordan for (int i = 0; i < n; i++) { // Recherche du pivot (valeur maximale dans la colonne pour limiter les erreurs) int pivot = i; for (int j = i + 1; j < n; j++) { if (matrix[j][i].abs() > matrix[pivot][i].abs()) { pivot = j; } } // Échange des lignes (si nécessaire) final List tempRow = matrix[i]; matrix[i] = matrix[pivot]; matrix[pivot] = tempRow; final double tempB = b[i]; b[i] = b[pivot]; b[pivot] = tempB; // Vérification de la singularité (division par zéro impossible) if (matrix[i][i].abs() < 1e-10) { return [1, 0, 0, 0, 1, 0, 0, 0, 1]; // Retourne identité si échec } // Normalisation de la ligne pivot for (int j = i + 1; j < n; j++) { final double factor = matrix[j][i] / matrix[i][i]; b[j] -= factor * b[i]; for (int k = i; k < n; k++) { matrix[j][k] -= factor * matrix[i][k]; } } } // Substitution arrière final List h = List.filled(9, 0.0); for (int i = n - 1; i >= 0; i--) { double sum = 0.0; for (int j = i + 1; j < n; j++) { sum += matrix[i][j] * h[j]; } h[i] = (b[i] - sum) / matrix[i][i]; } h[8] = 1.0; // Normalisation finale return h; } ({double x, double y}) _applyPerspectiveTransform( List h, double x, double y, ) { final w = h[6] * x + h[7] * y + h[8]; if (w.abs() < 1e-10) { return (x: x, y: y); } final nx = (h[0] * x + h[1] * y + h[2]) / w; final ny = (h[3] * x + h[4] * y + h[5]) / w; return (x: nx, y: ny); } /// Corrige la perspective en se basant sur la détection de cercles (ellipses) /// dans l'image. /// /// Cette méthode tente de détecter l'ellipse la plus proéminente (la cible) /// et calcule une transformation pour la rendre parfaitement circulaire. Future correctPerspectiveUsingCircles(String imagePath) async { try { // 1. Charger l'image avec OpenCV final src = cv.imread(imagePath, flags: cv.IMREAD_COLOR); if (src.isEmpty) throw Exception("Impossible de charger l'image"); // 2. Prétraitement final gray = cv.cvtColor(src, cv.COLOR_BGR2GRAY); final blurred = cv.gaussianBlur(gray, (5, 5), 0); // Canny edge detector avec seuil adaptatif (Otsu) final thresh = cv.threshold( blurred, 0, 255, cv.THRESH_BINARY | cv.THRESH_OTSU, ); final edges = cv.canny(blurred, thresh.$1 * 0.5, thresh.$1); // 3. Trouver les contours final contoursResult = cv.findContours( edges, cv.RETR_EXTERNAL, cv.CHAIN_APPROX_SIMPLE, ); final contours = contoursResult.$1; if (contours.isEmpty) return imagePath; // Pas de contours trouvés // 4. Trouver le meilleur candidat ellipse cv.RotatedRect? bestEllipse; double maxArea = 0; for (final contour in contours) { if (contour.length < 5) continue; // fitEllipse nécessite au moins 5 points final area = cv.contourArea(contour); if (area < 1000) continue; // Ignorer les trop petits bruits final ellipse = cv.fitEllipse(contour); // Critère de sélection: on cherche la plus grande ellipse qui est proche d'un cercle // Mais comme on veut corriger la distorsion, elle PEUT être aplatie. // Donc on prend juste la plus grande ellipse raisonnablement centrée. if (area > maxArea) { maxArea = area; bestEllipse = ellipse; } } if (bestEllipse == null) return imagePath; // 5. Calculer la transformation perspective // L'idée est de mapper les 4 sommets de l'ellipse détectée vers un cercle parfait. // Ou plus simplement, mapper le rectangle englobant de l'ellipse vers un carré. // Points source: les 4 coins du rotated rect de l'ellipse // Note: opencv_dart RotatedRect points() non dispo directement? // On peut utiliser boxPoints(ellipse) final boxPoints = cv.boxPoints(bestEllipse); // boxPoints returns Mat (4x2 float32) // Extraire les 4 points final List srcPoints = []; for (int i = 0; i < boxPoints.length; i++) { // On accède directement au point à l'index i final point2f = boxPoints[i]; // On convertit les coordonnées float en int pour cv.Point srcPoints.add(cv.Point(point2f.x.toInt(), point2f.y.toInt())); } // Trier les points pour avoir: TL, TR, BR, BL _sortPoints(srcPoints); // Dimensions cibles final side = math .max(bestEllipse.size.width, bestEllipse.size.height) .toInt(); final List dstPoints = [ cv.Point(0, 0), cv.Point(side, 0), cv.Point(side, side), cv.Point(0, side), ]; // Matrice de perspective final M = cv.getPerspectiveTransform( cv.VecPoint.fromList(srcPoints), cv.VecPoint.fromList(dstPoints), ); // 6. Warper l'image final corrected = cv.warpPerspective(src, M, (side, side)); // 7. Sauvegarder final tempDir = await getTemporaryDirectory(); final timestamp = DateTime.now().millisecondsSinceEpoch; final outputPath = '${tempDir.path}/corrected_circle_$timestamp.jpg'; cv.imwrite(outputPath, corrected); return outputPath; } catch (e) { // En cas d'erreur, retourner l'image originale print('Erreur correction perspective cercles: $e'); return imagePath; } } /// Trie les points dans l'ordre: Top-Left, Top-Right, Bottom-Right, Bottom-Left void _sortPoints(List points) { // Calculer le centre de gravité double cx = 0; double cy = 0; for (final p in points) { cx += p.x; cy += p.y; } cx /= points.length; cy /= points.length; points.sort((a, b) { // Trier par angle autour du centre final angleA = math.atan2(a.y - cy, a.x - cx); final angleB = math.atan2(b.y - cy, b.x - cx); return angleA.compareTo(angleB); }); // Re-trier pour être sûr: points.sort((a, b) => (a.y + a.x).compareTo(b.y + b.x)); final tl = points[0]; final br = points[3]; // Reste tr et bl final remaining = [points[1], points[2]]; remaining.sort((a, b) => a.x.compareTo(b.x)); final bl = remaining[0]; final tr = remaining[1]; points[0] = tl; points[1] = tr; points[2] = br; points[3] = bl; } /// Corrige la perspective en reformant le plus grand ovale (ellipse) en un cercle parfait, /// sans recadrer agressivement l'image entière. Future correctPerspectiveUsingOvals(String imagePath) async { try { final src = cv.imread(imagePath, flags: cv.IMREAD_COLOR); if (src.isEmpty) throw Exception("Impossible de charger l'image"); final gray = cv.cvtColor(src, cv.COLOR_BGR2GRAY); final blurred = cv.gaussianBlur(gray, (5, 5), 0); final thresh = cv.threshold( blurred, 0, 255, cv.THRESH_BINARY | cv.THRESH_OTSU, ); final edges = cv.canny(blurred, thresh.$1 * 0.5, thresh.$1); final contoursResult = cv.findContours( edges, cv.RETR_EXTERNAL, cv.CHAIN_APPROX_SIMPLE, ); final contours = contoursResult.$1; if (contours.isEmpty) return imagePath; cv.RotatedRect? bestEllipse; double maxArea = 0; for (final contour in contours) { if (contour.length < 5) continue; final area = cv.contourArea(contour); if (area < 1000) continue; final ellipse = cv.fitEllipse(contour); if (area > maxArea) { maxArea = area; bestEllipse = ellipse; } } if (bestEllipse == null) return imagePath; // The goal here is to morph the bestEllipse into a perfect circle, while // keeping the image the same size and the center of the ellipse in the same place. // We'll use the average of the width and height (or max) to define the target circle final targetRadius = math.max(bestEllipse.size.width, bestEllipse.size.height) / 2.0; // Extract the 4 bounding box points of the ellipse final boxPoints = cv.boxPoints(bestEllipse); final List srcPoints = []; for (int i = 0; i < boxPoints.length; i++) { srcPoints.add(cv.Point(boxPoints[i].x.toInt(), boxPoints[i].y.toInt())); } _sortPoints(srcPoints); // Calculate the size of the perfectly squared output image final int side = (targetRadius * 2).toInt(); final List dstPoints = [ cv.Point(0, 0), // Top-Left cv.Point(side, 0), // Top-Right cv.Point(side, side), // Bottom-Right cv.Point(0, side), // Bottom-Left ]; // Morph the target region into a perfect square, cropping the rest of the image final M = cv.getPerspectiveTransform( cv.VecPoint.fromList(srcPoints), cv.VecPoint.fromList(dstPoints), ); final corrected = cv.warpPerspective(src, M, (side, side)); final tempDir = await getTemporaryDirectory(); final timestamp = DateTime.now().millisecondsSinceEpoch; final outputPath = '${tempDir.path}/corrected_oval_$timestamp.jpg'; cv.imwrite(outputPath, corrected); return outputPath; } catch (e) { print('Erreur correction perspective ovales: $e'); return imagePath; } } /// Corrige la distorsion et la profondeur (perspective) en créant un maillage /// basé sur la concentricité des différents cercles de la cible pour trouver le meilleur plan. Future correctPerspectiveWithConcentricMesh(String imagePath) async { try { final src = cv.imread(imagePath, flags: cv.IMREAD_COLOR); if (src.isEmpty) throw Exception("Impossible de charger l'image"); final gray = cv.cvtColor(src, cv.COLOR_BGR2GRAY); final blurred = cv.gaussianBlur(gray, (5, 5), 0); final thresh = cv.threshold( blurred, 0, 255, cv.THRESH_BINARY | cv.THRESH_OTSU, ); final edges = cv.canny(blurred, thresh.$1 * 0.5, thresh.$1); final contoursResult = cv.findContours( edges, cv.RETR_LIST, cv.CHAIN_APPROX_SIMPLE, ); final contours = contoursResult.$1; if (contours.isEmpty) return imagePath; List ellipses = []; for (final contour in contours) { if (contour.length < 5) continue; if (cv.contourArea(contour) < 500) continue; ellipses.add(cv.fitEllipse(contour)); } if (ellipses.isEmpty) return imagePath; // Find the largest ellipse to serve as our central reference ellipses.sort( (a, b) => (b.size.width * b.size.height).compareTo( a.size.width * a.size.height, ), ); final largestEllipse = ellipses.first; final maxDist = math.max(largestEllipse.size.width, largestEllipse.size.height) * 0.15; // Group all ellipses that are roughly concentric with the largest one List concentricGroup = []; for (final e in ellipses) { final dx = e.center.x - largestEllipse.center.x; final dy = e.center.y - largestEllipse.center.y; if (math.sqrt(dx * dx + dy * dy) < maxDist) { concentricGroup.add(e); } } if (concentricGroup.length < 2) { print( "Pas assez de cercles concentriques pour le maillage, utilisation de la méthode simple.", ); return await correctPerspectiveUsingOvals(imagePath); } final targetRadius = math.max(largestEllipse.size.width, largestEllipse.size.height) / 2.0; final int side = (targetRadius * 2.4).toInt(); // Add padding final double cx = side / 2.0; final double cy = side / 2.0; List srcPointsList = []; List dstPointsList = []; for (final ellipse in concentricGroup) { final box = cv.boxPoints(ellipse); final m0 = cv.Point2f( (box[0].x + box[1].x) / 2, (box[0].y + box[1].y) / 2, ); final m1 = cv.Point2f( (box[1].x + box[2].x) / 2, (box[1].y + box[2].y) / 2, ); final m2 = cv.Point2f( (box[2].x + box[3].x) / 2, (box[2].y + box[3].y) / 2, ); final m3 = cv.Point2f( (box[3].x + box[0].x) / 2, (box[3].y + box[0].y) / 2, ); final d02 = math.sqrt( math.pow(m0.x - m2.x, 2) + math.pow(m0.y - m2.y, 2), ); final d13 = math.sqrt( math.pow(m1.x - m3.x, 2) + math.pow(m1.y - m3.y, 2), ); cv.Point2f maj1, maj2, min1, min2; double r; if (d02 > d13) { maj1 = m0; maj2 = m2; min1 = m1; min2 = m3; r = d02 / 2.0; } else { maj1 = m1; maj2 = m3; min1 = m0; min2 = m2; r = d13 / 2.0; } // Sort maj1 and maj2 so maj1 is left/top if ((maj1.x - maj2.x).abs() > (maj1.y - maj2.y).abs()) { if (maj1.x > maj2.x) { final t = maj1; maj1 = maj2; maj2 = t; } } else { if (maj1.y > maj2.y) { final t = maj1; maj1 = maj2; maj2 = t; } } // Sort min1 and min2 so min1 is top/left if ((min1.y - min2.y).abs() > (min1.x - min2.x).abs()) { if (min1.y > min2.y) { final t = min1; min1 = min2; min2 = t; } } else { if (min1.x > min2.x) { final t = min1; min1 = min2; min2 = t; } } srcPointsList.addAll([maj1, maj2, min1, min2]); dstPointsList.addAll([ cv.Point2f(cx - r, cy), cv.Point2f(cx + r, cy), cv.Point2f(cx, cy - r), cv.Point2f(cx, cy + r), ]); // Add ellipse centers mapping perfectly to the origin to force concentric depth alignment srcPointsList.add(cv.Point2f(ellipse.center.x, ellipse.center.y)); dstPointsList.add(cv.Point2f(cx, cy)); } // We explicitly convert points to VecPoint to use findHomography standard binding final srcVec = cv.VecPoint.fromList( srcPointsList.map((p) => cv.Point(p.x.toInt(), p.y.toInt())).toList(), ); final dstVec = cv.VecPoint.fromList( dstPointsList.map((p) => cv.Point(p.x.toInt(), p.y.toInt())).toList(), ); final M = cv.findHomography( cv.Mat.fromVec(srcVec), cv.Mat.fromVec(dstVec), method: cv.RANSAC, ); if (M.isEmpty) { return await correctPerspectiveUsingOvals(imagePath); } final corrected = cv.warpPerspective(src, M, (side, side)); final tempDir = await getTemporaryDirectory(); final timestamp = DateTime.now().millisecondsSinceEpoch; final outputPath = '${tempDir.path}/corrected_mesh_$timestamp.jpg'; cv.imwrite(outputPath, corrected); return outputPath; } catch (e) { print('Erreur correction perspective maillage concentrique: $e'); return imagePath; } } /// Corrige la perspective en détectant les 4 coins de la feuille (quadrilatère) /// /// Cette méthode cherche le plus grand polygone à 4 côtés (le bord du papier) /// et le déforme pour en faire un carré parfait. Future correctPerspectiveUsingQuadrilateral(String imagePath) async { try { final src = cv.imread(imagePath, flags: cv.IMREAD_COLOR); if (src.isEmpty) throw Exception("Impossible de charger l'image"); final gray = cv.cvtColor(src, cv.COLOR_BGR2GRAY); // Flou plus important pour ignorer les détails internes (cercles, trous) final blurred = cv.gaussianBlur(gray, (9, 9), 0); // Canny edge detector final thresh = cv.threshold( blurred, 0, 255, cv.THRESH_BINARY | cv.THRESH_OTSU, ); final edges = cv.canny(blurred, thresh.$1 * 0.5, thresh.$1); // Pour la détection de la feuille (les bords peuvent être discontinus à cause de l'éclairage) final kernel = cv.getStructuringElement(cv.MORPH_RECT, (5, 5)); final closedEdges = cv.morphologyEx(edges, cv.MORPH_CLOSE, kernel); // Find contours final contoursResult = cv.findContours( closedEdges, cv.RETR_EXTERNAL, cv.CHAIN_APPROX_SIMPLE, ); final contours = contoursResult.$1; cv.VecPoint? bestQuad; double maxArea = 0; final minArea = src.rows * src.cols * 0.1; // Au moins 10% de l'image for (final contour in contours) { final area = cv.contourArea(contour); if (area < minArea) continue; final peri = cv.arcLength(contour, true); // Approximation polygonale (tolérance = 2% à 5% du périmètre) final approx = cv.approxPolyDP(contour, 0.04 * peri, true); if (approx.length == 4) { if (area > maxArea) { maxArea = area; bestQuad = approx; } } } // Fallback if (bestQuad == null) { print( "Aucun papier quadrilatère détecté, on utilise les cercles à la place.", ); return await correctPerspectiveUsingCircles(imagePath); } // Convert to List final List srcPoints = []; for (int i = 0; i < bestQuad.length; i++) { srcPoints.add(bestQuad[i]); } _sortPoints(srcPoints); // Calculate max width and height double widthA = _distanceCV(srcPoints[2], srcPoints[3]); double widthB = _distanceCV(srcPoints[1], srcPoints[0]); int dstWidth = math.max(widthA, widthB).toInt(); double heightA = _distanceCV(srcPoints[1], srcPoints[2]); double heightB = _distanceCV(srcPoints[0], srcPoints[3]); int dstHeight = math.max(heightA, heightB).toInt(); // Since standard target paper forms a square, we force the resulting warp to be a perfect square. int side = math.max(dstWidth, dstHeight); final List dstPoints = [ cv.Point(0, 0), cv.Point(side, 0), cv.Point(side, side), cv.Point(0, side), ]; final M = cv.getPerspectiveTransform( cv.VecPoint.fromList(srcPoints), cv.VecPoint.fromList(dstPoints), ); final corrected = cv.warpPerspective(src, M, (side, side)); final tempDir = await getTemporaryDirectory(); final timestamp = DateTime.now().millisecondsSinceEpoch; final outputPath = '${tempDir.path}/corrected_quad_$timestamp.jpg'; cv.imwrite(outputPath, corrected); return outputPath; } catch (e) { print('Erreur correction perspective quadrilatère: $e'); // Fallback return await correctPerspectiveUsingCircles(imagePath); } } double _distanceCV(cv.Point p1, cv.Point p2) { final dx = p2.x - p1.x; final dy = p2.y - p1.y; return math.sqrt(dx * dx + dy * dy); } }