feat: target rectify service pour perfectionner la prise de photo avec detection
This commit is contained in:
@@ -19,6 +19,7 @@ import '../../services/image_crop_service.dart';
|
|||||||
import '../../services/opencv_target_service.dart';
|
import '../../services/opencv_target_service.dart';
|
||||||
import '../../services/parallelism_service.dart'; // NOUVEAU
|
import '../../services/parallelism_service.dart'; // NOUVEAU
|
||||||
import '../../services/object_detection_service.dart'; // NOUVEAU : ML Kit
|
import '../../services/object_detection_service.dart'; // NOUVEAU : ML Kit
|
||||||
|
import '../../services/target_rectify_service.dart'; // NOUVEAU : redressement
|
||||||
import 'package:image/image.dart' as img;
|
import 'package:image/image.dart' as img;
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
@@ -69,6 +70,9 @@ class _CaptureScreenState extends State<CaptureScreen>
|
|||||||
StreamSubscription<List<DetectedObject2D>>? _objectSubscription;
|
StreamSubscription<List<DetectedObject2D>>? _objectSubscription;
|
||||||
List<DetectedObject2D> _detectedObjects = [];
|
List<DetectedObject2D> _detectedObjects = [];
|
||||||
|
|
||||||
|
// NOUVEAU : Service de redressement de cible (warp perspective)
|
||||||
|
final TargetRectifyService _rectifyService = TargetRectifyService();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -506,14 +510,15 @@ class _CaptureScreenState extends State<CaptureScreen>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// NOUVEAU : 1.bis Cadre dessiné autour de l'objet détecté
|
// NOUVEAU : 1.bis Cadre dessiné autour de CHAQUE objet détecté
|
||||||
if (_primaryObject != null)
|
if (_detectedObjects.isNotEmpty)
|
||||||
Center(
|
Center(
|
||||||
child: AspectRatio(
|
child: AspectRatio(
|
||||||
aspectRatio: 3 / 4,
|
aspectRatio: 3 / 4,
|
||||||
child: CustomPaint(
|
child: CustomPaint(
|
||||||
painter: _ObjectBoxPainter(
|
painter: _ObjectBoxPainter(
|
||||||
object: _primaryObject!,
|
objects: _detectedObjects,
|
||||||
|
primary: _primaryObject,
|
||||||
color: frameColor,
|
color: frameColor,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -792,8 +797,39 @@ class _CaptureScreenState extends State<CaptureScreen>
|
|||||||
|
|
||||||
final XFile photo = await _cameraController!.takePicture();
|
final XFile photo = await _cameraController!.takePicture();
|
||||||
|
|
||||||
|
// NOUVEAU : redressement automatique de la cible (warp perspective).
|
||||||
|
// On écrit le résultat dans un fichier dédié, à côté de l'original.
|
||||||
|
String finalPath = photo.path;
|
||||||
|
try {
|
||||||
|
final tempDir = await getTemporaryDirectory();
|
||||||
|
final rectifiedPath =
|
||||||
|
'${tempDir.path}/rectified_${DateTime.now().millisecondsSinceEpoch}.jpg';
|
||||||
|
|
||||||
|
final result = await _rectifyService.rectify(
|
||||||
|
inputPath: photo.path,
|
||||||
|
outputPath: rectifiedPath,
|
||||||
|
);
|
||||||
|
|
||||||
|
finalPath = result.outputPath;
|
||||||
|
|
||||||
|
if (mounted && result.rectified) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
duration: const Duration(seconds: 2),
|
||||||
|
content: Text(
|
||||||
|
'Cible redressée automatiquement '
|
||||||
|
'(inclinaison ${result.estimatedTiltDegrees.toStringAsFixed(1)}° corrigée)',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Redressement ignoré: $e');
|
||||||
|
finalPath = photo.path; // on garde la photo originale en secours
|
||||||
|
}
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_selectedImagePath = photo.path;
|
_selectedImagePath = finalPath;
|
||||||
_showLiveCamera = false;
|
_showLiveCamera = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -902,58 +938,66 @@ class _CaptureScreenState extends State<CaptureScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
// NOUVEAU : Painter qui dessine la boîte englobante de l'objet détecté.
|
// Painter qui dessine la boîte englobante de CHAQUE objet détecté.
|
||||||
// Les coordonnées de l'objet sont normalisées (0..1) → on les multiplie
|
// L'objet principal (le plus grand et centré) est mis en évidence avec
|
||||||
// par la taille réelle de la zone de dessin (qui suit l'AspectRatio 3/4).
|
// la couleur d'état (vert/orange) ; les autres en blanc translucide.
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
class _ObjectBoxPainter extends CustomPainter {
|
class _ObjectBoxPainter extends CustomPainter {
|
||||||
final DetectedObject2D object;
|
final List<DetectedObject2D> objects;
|
||||||
|
final DetectedObject2D? primary;
|
||||||
final Color color;
|
final Color color;
|
||||||
|
|
||||||
_ObjectBoxPainter({required this.object, required this.color});
|
_ObjectBoxPainter({
|
||||||
|
required this.objects,
|
||||||
|
required this.primary,
|
||||||
|
required this.color,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void paint(Canvas canvas, Size size) {
|
void paint(Canvas canvas, Size size) {
|
||||||
final Paint stroke = Paint()
|
for (final obj in objects) {
|
||||||
..style = PaintingStyle.stroke
|
final bool isPrimary = identical(obj, primary);
|
||||||
..strokeWidth = 3.0
|
final Color boxColor =
|
||||||
..color = color;
|
isPrimary ? color : Colors.white.withValues(alpha: 0.6);
|
||||||
|
|
||||||
final Rect rect = Rect.fromLTRB(
|
final Paint stroke = Paint()
|
||||||
object.left * size.width,
|
..style = PaintingStyle.stroke
|
||||||
object.top * size.height,
|
..strokeWidth = isPrimary ? 3.0 : 1.5
|
||||||
object.right * size.width,
|
..color = boxColor;
|
||||||
object.bottom * size.height,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Boîte à coins arrondis
|
final Rect rect = Rect.fromLTRB(
|
||||||
final RRect rrect =
|
obj.left * size.width,
|
||||||
RRect.fromRectAndRadius(rect, const Radius.circular(8));
|
obj.top * size.height,
|
||||||
canvas.drawRRect(rrect, stroke);
|
obj.right * size.width,
|
||||||
|
obj.bottom * size.height,
|
||||||
// Petit label si disponible
|
|
||||||
if (object.label.isNotEmpty) {
|
|
||||||
final textPainter = TextPainter(
|
|
||||||
text: TextSpan(
|
|
||||||
text: ' ${object.label} '
|
|
||||||
'${(object.confidence * 100).toStringAsFixed(0)}% ',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.black,
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
backgroundColor: color,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
textDirection: TextDirection.ltr,
|
|
||||||
)..layout();
|
|
||||||
textPainter.paint(
|
|
||||||
canvas,
|
|
||||||
Offset(rect.left, (rect.top - 18).clamp(0.0, size.height)),
|
|
||||||
);
|
);
|
||||||
|
final RRect rrect =
|
||||||
|
RRect.fromRectAndRadius(rect, const Radius.circular(8));
|
||||||
|
canvas.drawRRect(rrect, stroke);
|
||||||
|
|
||||||
|
if (obj.label.isNotEmpty) {
|
||||||
|
final textPainter = TextPainter(
|
||||||
|
text: TextSpan(
|
||||||
|
text: ' ${obj.label} '
|
||||||
|
'${(obj.confidence * 100).toStringAsFixed(0)}% ',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.black,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
backgroundColor: boxColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
textDirection: TextDirection.ltr,
|
||||||
|
)..layout();
|
||||||
|
textPainter.paint(
|
||||||
|
canvas,
|
||||||
|
Offset(rect.left, (rect.top - 18).clamp(0.0, size.height)),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool shouldRepaint(_ObjectBoxPainter old) =>
|
bool shouldRepaint(_ObjectBoxPainter old) =>
|
||||||
old.object != object || old.color != color;
|
old.objects != objects || old.color != color || old.primary != primary;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ class DetectedObject2D {
|
|||||||
class ObjectDetectionService {
|
class ObjectDetectionService {
|
||||||
ObjectDetector? _detector;
|
ObjectDetector? _detector;
|
||||||
final StreamController<List<DetectedObject2D>> _controller =
|
final StreamController<List<DetectedObject2D>> _controller =
|
||||||
StreamController<List<DetectedObject2D>>.broadcast();
|
StreamController<List<DetectedObject2D>>.broadcast();
|
||||||
|
|
||||||
bool _isBusy = false;
|
bool _isBusy = false;
|
||||||
bool _started = false;
|
bool _started = false;
|
||||||
@@ -61,22 +61,22 @@ class ObjectDetectionService {
|
|||||||
_started = true;
|
_started = true;
|
||||||
|
|
||||||
// Mode STREAM : optimisé pour le flux vidéo temps réel.
|
// Mode STREAM : optimisé pour le flux vidéo temps réel.
|
||||||
// classifyObjects: true => on récupère un label grossier + confiance.
|
// classifyObjects: true => label grossier + confiance par objet.
|
||||||
// multipleObjects: false => on suit l'objet principal (plus stable).
|
// multipleObjects: true => on détecte et encadre TOUS les objets visibles.
|
||||||
final options = ObjectDetectorOptions(
|
final options = ObjectDetectorOptions(
|
||||||
mode: DetectionMode.stream,
|
mode: DetectionMode.stream,
|
||||||
classifyObjects: true,
|
classifyObjects: true,
|
||||||
multipleObjects: false,
|
multipleObjects: true,
|
||||||
);
|
);
|
||||||
_detector = ObjectDetector(options: options);
|
_detector = ObjectDetector(options: options);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// À appeler depuis `startImageStream`.
|
/// À appeler depuis `startImageStream`.
|
||||||
Future<void> processCameraImage(
|
Future<void> processCameraImage(
|
||||||
CameraImage image,
|
CameraImage image,
|
||||||
CameraDescription camera,
|
CameraDescription camera,
|
||||||
DeviceOrientation deviceOrientation,
|
DeviceOrientation deviceOrientation,
|
||||||
) async {
|
) async {
|
||||||
if (!_started || _detector == null) return;
|
if (!_started || _detector == null) return;
|
||||||
if (_isBusy) return; // On saute la frame si la précédente n'est pas finie
|
if (_isBusy) return; // On saute la frame si la précédente n'est pas finie
|
||||||
_isBusy = true;
|
_isBusy = true;
|
||||||
@@ -99,7 +99,7 @@ class ObjectDetectionService {
|
|||||||
double conf = 0;
|
double conf = 0;
|
||||||
if (o.labels.isNotEmpty) {
|
if (o.labels.isNotEmpty) {
|
||||||
final best = o.labels.reduce(
|
final best = o.labels.reduce(
|
||||||
(a, b) => a.confidence >= b.confidence ? a : b,
|
(a, b) => a.confidence >= b.confidence ? a : b,
|
||||||
);
|
);
|
||||||
label = best.text;
|
label = best.text;
|
||||||
conf = best.confidence;
|
conf = best.confidence;
|
||||||
@@ -124,10 +124,10 @@ class ObjectDetectionService {
|
|||||||
|
|
||||||
/// Convertit une CameraImage en InputImage ML Kit.
|
/// Convertit une CameraImage en InputImage ML Kit.
|
||||||
InputImage? _toInputImage(
|
InputImage? _toInputImage(
|
||||||
CameraImage image,
|
CameraImage image,
|
||||||
CameraDescription camera,
|
CameraDescription camera,
|
||||||
DeviceOrientation deviceOrientation,
|
DeviceOrientation deviceOrientation,
|
||||||
) {
|
) {
|
||||||
// Rotation : combine l'orientation du capteur et celle de l'appareil.
|
// Rotation : combine l'orientation du capteur et celle de l'appareil.
|
||||||
final sensorOrientation = camera.sensorOrientation;
|
final sensorOrientation = camera.sensorOrientation;
|
||||||
InputImageRotation? rotation;
|
InputImageRotation? rotation;
|
||||||
@@ -183,4 +183,4 @@ class ObjectDetectionService {
|
|||||||
_detector = null;
|
_detector = null;
|
||||||
_controller.close();
|
_controller.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
259
lib/services/target_rectify_service.dart
Normal file
259
lib/services/target_rectify_service.dart
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:math' as math;
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:opencv_dart/opencv_dart.dart' as cv;
|
||||||
|
|
||||||
|
/// Résultat d'une tentative de redressement de cible.
|
||||||
|
class RectifyResult {
|
||||||
|
/// Chemin du fichier image redressé (ou original si échec).
|
||||||
|
final String outputPath;
|
||||||
|
|
||||||
|
/// true si une cible a été détectée et redressée, false si on a renvoyé
|
||||||
|
/// l'image d'origine sans transformation.
|
||||||
|
final bool rectified;
|
||||||
|
|
||||||
|
/// Angle d'inclinaison estimé de la cible AVANT redressement, en degrés.
|
||||||
|
/// (0 = déjà de face). Utile pour informer l'utilisateur.
|
||||||
|
final double estimatedTiltDegrees;
|
||||||
|
|
||||||
|
/// Message de diagnostic (utile pour debug / affichage).
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
const RectifyResult({
|
||||||
|
required this.outputPath,
|
||||||
|
required this.rectified,
|
||||||
|
required this.estimatedTiltDegrees,
|
||||||
|
required this.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Service qui redresse une cible RONDE (cercles concentriques) photographiée
|
||||||
|
/// de biais, en la ramenant parfaitement de face.
|
||||||
|
///
|
||||||
|
/// Principe :
|
||||||
|
/// 1. Détecter le plus grand contour ~circulaire de l'image.
|
||||||
|
/// 2. Ajuster une ELLIPSE sur ce contour (fitEllipse).
|
||||||
|
/// 3. Un cercle vu en perspective devient une ellipse : on calcule la
|
||||||
|
/// transformation de perspective qui remappe cette ellipse vers un
|
||||||
|
/// CERCLE parfait, et on l'applique à toute l'image.
|
||||||
|
/// 4. La cible apparaît alors de face.
|
||||||
|
///
|
||||||
|
/// L'image résultat est carrée et centrée sur la cible.
|
||||||
|
class TargetRectifyService {
|
||||||
|
/// Taille (en pixels) du côté de l'image carrée de sortie.
|
||||||
|
final int outputSize;
|
||||||
|
|
||||||
|
/// Marge autour de la cible dans l'image de sortie (1.0 = cible pile au bord,
|
||||||
|
/// 1.3 = 30 % de marge autour). Garde un peu de contexte.
|
||||||
|
final double marginFactor;
|
||||||
|
|
||||||
|
/// En dessous de cet écart d'axes (ratio petit/grand axe proche de 1),
|
||||||
|
/// la cible est considérée déjà de face → pas de warp inutile.
|
||||||
|
final double minTiltRatioToRectify;
|
||||||
|
|
||||||
|
TargetRectifyService({
|
||||||
|
this.outputSize = 1024,
|
||||||
|
this.marginFactor = 1.25,
|
||||||
|
this.minTiltRatioToRectify = 0.985,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Redresse l'image située à [inputPath]. Écrit le résultat dans
|
||||||
|
/// [outputPath] et renvoie un [RectifyResult].
|
||||||
|
///
|
||||||
|
/// Ne bloque jamais : en cas d'échec de détection, renvoie l'image
|
||||||
|
/// d'origine (rectified = false) pour ne pas perdre la photo du tireur.
|
||||||
|
Future<RectifyResult> rectify({
|
||||||
|
required String inputPath,
|
||||||
|
required String outputPath,
|
||||||
|
}) async {
|
||||||
|
cv.Mat? src;
|
||||||
|
cv.Mat? gray;
|
||||||
|
cv.Mat? blurred;
|
||||||
|
cv.Mat? edges;
|
||||||
|
try {
|
||||||
|
src = cv.imread(inputPath, flags: cv.IMREAD_COLOR);
|
||||||
|
if (src.isEmpty) {
|
||||||
|
return RectifyResult(
|
||||||
|
outputPath: inputPath,
|
||||||
|
rectified: false,
|
||||||
|
estimatedTiltDegrees: 0,
|
||||||
|
message: 'Image illisible',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 1. Prétraitement ────────────────────────────────────────────────
|
||||||
|
gray = cv.cvtColor(src, cv.COLOR_BGR2GRAY);
|
||||||
|
blurred = cv.gaussianBlur(gray, (5, 5), 2, sigmaY: 2);
|
||||||
|
edges = cv.canny(blurred, 60, 160);
|
||||||
|
|
||||||
|
// Dilatation légère pour fermer les contours brisés
|
||||||
|
final cv.Mat kernel = cv.getStructuringElement(
|
||||||
|
cv.MORPH_ELLIPSE,
|
||||||
|
(3, 3),
|
||||||
|
);
|
||||||
|
final cv.Mat dilated = cv.dilate(edges, kernel);
|
||||||
|
|
||||||
|
// ── 2. Recherche du meilleur contour elliptique ──────────────────────
|
||||||
|
final (contours, _) = cv.findContours(
|
||||||
|
dilated,
|
||||||
|
cv.RETR_EXTERNAL,
|
||||||
|
cv.CHAIN_APPROX_SIMPLE,
|
||||||
|
);
|
||||||
|
|
||||||
|
final double imgArea = (src.width * src.height).toDouble();
|
||||||
|
cv.RotatedRect? bestEllipse;
|
||||||
|
double bestScore = 0;
|
||||||
|
|
||||||
|
for (int i = 0; i < contours.length; i++) {
|
||||||
|
final c = contours[i];
|
||||||
|
if (c.length < 5) continue; // fitEllipse exige >= 5 points
|
||||||
|
|
||||||
|
final double area = cv.contourArea(c);
|
||||||
|
// On ignore les contours minuscules et ceux qui couvrent presque tout
|
||||||
|
if (area < imgArea * 0.03 || area > imgArea * 0.97) continue;
|
||||||
|
|
||||||
|
final cv.RotatedRect e = cv.fitEllipse(c);
|
||||||
|
final ep = e.points; // 4 sommets
|
||||||
|
if (ep.length < 4) continue;
|
||||||
|
final double ecx = (ep[0].x + ep[1].x + ep[2].x + ep[3].x) / 4.0;
|
||||||
|
final double ecy = (ep[0].y + ep[1].y + ep[2].y + ep[3].y) / 4.0;
|
||||||
|
final double mAx = (ep[0].x + ep[1].x) / 2.0;
|
||||||
|
final double mAy = (ep[0].y + ep[1].y) / 2.0;
|
||||||
|
final double mBx = (ep[1].x + ep[2].x) / 2.0;
|
||||||
|
final double mBy = (ep[1].y + ep[2].y) / 2.0;
|
||||||
|
final double w =
|
||||||
|
2 * math.sqrt(math.pow(mAx - ecx, 2) + math.pow(mAy - ecy, 2));
|
||||||
|
final double h =
|
||||||
|
2 * math.sqrt(math.pow(mBx - ecx, 2) + math.pow(mBy - ecy, 2));
|
||||||
|
if (w <= 1 || h <= 1) continue;
|
||||||
|
|
||||||
|
// À quel point le contour ressemble-t-il vraiment à son ellipse ?
|
||||||
|
// On compare l'aire du contour à l'aire de l'ellipse ajustée.
|
||||||
|
final double ellipseArea = math.pi * (w / 2) * (h / 2);
|
||||||
|
if (ellipseArea <= 0) continue;
|
||||||
|
final double fitRatio = area / ellipseArea; // ~1 si bon ajustement
|
||||||
|
if (fitRatio < 0.7 || fitRatio > 1.3) continue;
|
||||||
|
|
||||||
|
// Score = taille de l'ellipse (on veut la cible la plus grande)
|
||||||
|
final double score = ellipseArea;
|
||||||
|
if (score > bestScore) {
|
||||||
|
bestScore = score;
|
||||||
|
bestEllipse = e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bestEllipse == null) {
|
||||||
|
return RectifyResult(
|
||||||
|
outputPath: inputPath,
|
||||||
|
rectified: false,
|
||||||
|
estimatedTiltDegrees: 0,
|
||||||
|
message: 'Aucune cible circulaire détectée',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 3. Extraire les 4 sommets de l'ellipse (robuste à la version d'API) ─
|
||||||
|
// RotatedRect.points renvoie les 4 coins de la boîte englobant l'ellipse.
|
||||||
|
// On en dérive nous-mêmes le centre, les demi-axes et l'orientation, ce
|
||||||
|
// qui évite de dépendre de la forme exacte de `.size` / `.center`
|
||||||
|
// (record vs objet) qui varie selon les versions d'opencv_dart.
|
||||||
|
final pts = bestEllipse.points; // List<Point2f> de 4 sommets
|
||||||
|
|
||||||
|
double px(int i) => pts[i].x;
|
||||||
|
double py(int i) => pts[i].y;
|
||||||
|
|
||||||
|
// Centre = moyenne des 4 sommets
|
||||||
|
final double cx = (px(0) + px(1) + px(2) + px(3)) / 4.0;
|
||||||
|
final double cy = (py(0) + py(1) + py(2) + py(3)) / 4.0;
|
||||||
|
|
||||||
|
// Milieux de deux côtés adjacents → extrémités des deux demi-axes
|
||||||
|
// Côté 0-1 et côté 1-2 (ordre des sommets d'un RotatedRect)
|
||||||
|
final double m01x = (px(0) + px(1)) / 2.0;
|
||||||
|
final double m01y = (py(0) + py(1)) / 2.0;
|
||||||
|
final double m12x = (px(1) + px(2)) / 2.0;
|
||||||
|
final double m12y = (py(1) + py(2)) / 2.0;
|
||||||
|
|
||||||
|
// Demi-axes = distance centre → milieu de chaque côté
|
||||||
|
final double axisA =
|
||||||
|
math.sqrt(math.pow(m01x - cx, 2) + math.pow(m01y - cy, 2));
|
||||||
|
final double axisB =
|
||||||
|
math.sqrt(math.pow(m12x - cx, 2) + math.pow(m12y - cy, 2));
|
||||||
|
|
||||||
|
final double majorAxis = math.max(axisA, axisB);
|
||||||
|
final double minorAxis = math.min(axisA, axisB);
|
||||||
|
if (majorAxis <= 1) {
|
||||||
|
return RectifyResult(
|
||||||
|
outputPath: inputPath,
|
||||||
|
rectified: false,
|
||||||
|
estimatedTiltDegrees: 0,
|
||||||
|
message: 'Ellipse dégénérée',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final double axisRatio = minorAxis / majorAxis; // 1 = cercle parfait
|
||||||
|
final double tiltDeg =
|
||||||
|
math.acos(axisRatio.clamp(0.0, 1.0)) * (180.0 / math.pi);
|
||||||
|
|
||||||
|
// Déjà quasiment de face → on ne touche pas (évite le flou inutile)
|
||||||
|
if (axisRatio >= minTiltRatioToRectify) {
|
||||||
|
cv.imwrite(outputPath, src);
|
||||||
|
return RectifyResult(
|
||||||
|
outputPath: outputPath,
|
||||||
|
rectified: false,
|
||||||
|
estimatedTiltDegrees: tiltDeg,
|
||||||
|
message: 'Cible déjà de face',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 4. Construire la transformation de perspective ────────────────────
|
||||||
|
// Source : extrémités des deux axes de l'ellipse (4 points).
|
||||||
|
// Destination : extrémités des axes d'un cercle parfait centré.
|
||||||
|
// On obtient les extrémités en prolongeant centre→milieu-de-côté.
|
||||||
|
final srcPts = cv.VecPoint.fromList([
|
||||||
|
cv.Point((cx + (m01x - cx)).round(), (cy + (m01y - cy)).round()),
|
||||||
|
cv.Point((cx - (m01x - cx)).round(), (cy - (m01y - cy)).round()),
|
||||||
|
cv.Point((cx + (m12x - cx)).round(), (cy + (m12y - cy)).round()),
|
||||||
|
cv.Point((cx - (m12x - cx)).round(), (cy - (m12y - cy)).round()),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Cible : cercle parfait centré, rayon R, dans une image carrée.
|
||||||
|
// L'axe "A" (m01) devient l'axe horizontal, l'axe "B" (m12) le vertical.
|
||||||
|
final double out = outputSize.toDouble();
|
||||||
|
final double center = out / 2;
|
||||||
|
final double radius = (out / 2) / marginFactor;
|
||||||
|
final dstPts = cv.VecPoint.fromList([
|
||||||
|
cv.Point((center + radius).round(), center.round()),
|
||||||
|
cv.Point((center - radius).round(), center.round()),
|
||||||
|
cv.Point(center.round(), (center + radius).round()),
|
||||||
|
cv.Point(center.round(), (center - radius).round()),
|
||||||
|
]);
|
||||||
|
|
||||||
|
final cv.Mat transform = cv.getPerspectiveTransform(srcPts, dstPts);
|
||||||
|
|
||||||
|
final cv.Mat warped = cv.warpPerspective(
|
||||||
|
src,
|
||||||
|
transform,
|
||||||
|
(outputSize, outputSize),
|
||||||
|
flags: cv.INTER_LINEAR,
|
||||||
|
borderMode: cv.BORDER_CONSTANT,
|
||||||
|
);
|
||||||
|
|
||||||
|
cv.imwrite(outputPath, warped);
|
||||||
|
|
||||||
|
return RectifyResult(
|
||||||
|
outputPath: outputPath,
|
||||||
|
rectified: true,
|
||||||
|
estimatedTiltDegrees: tiltDeg,
|
||||||
|
message: 'Cible redressée (inclinaison ${tiltDeg.toStringAsFixed(1)}°)',
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('TargetRectify erreur: $e');
|
||||||
|
// En cas de pépin, on renvoie l'original pour ne jamais perdre la photo
|
||||||
|
return RectifyResult(
|
||||||
|
outputPath: inputPath,
|
||||||
|
rectified: false,
|
||||||
|
estimatedTiltDegrees: 0,
|
||||||
|
message: 'Erreur de traitement: $e',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user