ajout correction de distotion d'image

This commit is contained in:
2026-01-18 21:32:42 +01:00
parent 6b0cb8f837
commit f1a8eefdc3
3 changed files with 543 additions and 3 deletions

View File

@@ -0,0 +1,423 @@
/// 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: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 = <double>[];
final angles = <double>[];
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<String> 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<String> 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<double> _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<List<double>>.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;
}
List<double> _solveHomography(List<List<double>> a) {
// Implémentation simplifiée - normalisation et résolution
// En pratique, on devrait utiliser une vraie décomposition SVD
// Pour l'instant, retourner une matrice identité
// TODO: Implémenter une vraie résolution
return [1, 0, 0, 0, 1, 0, 0, 0, 1];
}
({double x, double y}) _applyPerspectiveTransform(List<double> 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);
}
}