From 251d4bb5992610f5fc93be0a54ccfa4e6363305f Mon Sep 17 00:00:00 2001 From: qguillaume Date: Fri, 5 Jun 2026 17:18:29 +0200 Subject: [PATCH] fix: lenteur de l appli --- lib/features/capture/capture_screen.dart | 163 +++++++++++++++++--- lib/services/image_crop_service.dart | 180 ++++++++++++----------- 2 files changed, 237 insertions(+), 106 deletions(-) diff --git a/lib/features/capture/capture_screen.dart b/lib/features/capture/capture_screen.dart index eb87060e..2b68103c 100644 --- a/lib/features/capture/capture_screen.dart +++ b/lib/features/capture/capture_screen.dart @@ -1,5 +1,6 @@ import 'dart:io'; import 'dart:math' as math; +import 'dart:async'; // AJOUT : Pour le Timer de détection périodique import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; import 'package:permission_handler/permission_handler.dart'; @@ -14,6 +15,8 @@ import '../crop/crop_screen.dart'; import '../session/session_provider.dart'; import 'widgets/image_source_button.dart'; import '../../services/image_crop_service.dart'; +import '../../services/opencv_target_service.dart'; // AJOUT : Pour la détection périodique + class CaptureScreen extends StatefulWidget { const CaptureScreen({super.key}); @@ -25,10 +28,10 @@ class _CaptureScreenState extends State with SingleTickerProvider final ImagePicker _picker = ImagePicker(); final ImageCropService _cropService = ImageCropService(); final TargetType _selectedType = TargetType.concentric; + final OpenCVTargetService _opencvService = OpenCVTargetService(); // AJOUT : Service de détection String? _selectedImagePath; bool _isLoading = false; - // AJOUT : Variables de gestion du flux caméra en direct CameraController? _cameraController; List? _cameras; @@ -39,6 +42,12 @@ class _CaptureScreenState extends State with SingleTickerProvider late AnimationController _scanAnimationController; late Animation _scanAnimation; + // AJOUT : Variables pour le parallélisme + // _alignmentStatus : null = pas encore analysé, true = bien aligné, false = mal aligné + bool? _alignmentStatus; + Timer? _detectionTimer; + bool _isAnalyzingFrame = false; // Verrou pour éviter les appels OpenCV simultanés + @override void initState() { super.initState(); @@ -57,6 +66,7 @@ class _CaptureScreenState extends State with SingleTickerProvider void dispose() { _cameraController?.dispose(); _scanAnimationController.dispose(); + _detectionTimer?.cancel(); // AJOUT : On stoppe le timer proprement super.dispose(); } @@ -74,6 +84,14 @@ class _CaptureScreenState extends State with SingleTickerProvider setState(() => _isLoading = true); try { + // CORRECTIF ZOOM : On dispose l'ancien contrôleur avant d'en créer un nouveau + // pour garantir un zoom à 1.0 à chaque ouverture de la caméra + if (_cameraController != null) { + await _cameraController!.dispose(); + _cameraController = null; + _isCameraInitialized = false; + } + _cameras = await availableCameras(); if (_cameras != null && _cameras!.isNotEmpty) { _cameraController = CameraController( @@ -89,7 +107,11 @@ class _CaptureScreenState extends State with SingleTickerProvider setState(() { _isCameraInitialized = true; _showLiveCamera = true; + _alignmentStatus = null; // AJOUT : Reset du statut d'alignement à chaque ouverture }); + + // AJOUT : Démarrage du timer de détection périodique (toutes les 2 secondes) + _startAlignmentDetection(); } } catch (e) { debugPrint('Erreur caméra: $e'); @@ -98,6 +120,66 @@ class _CaptureScreenState extends State with SingleTickerProvider } } + // AJOUT : Démarre la détection périodique du parallélisme toutes les 2 secondes + void _startAlignmentDetection() { + _detectionTimer?.cancel(); + _detectionTimer = Timer.periodic(const Duration(seconds: 2), (_) { + _analyzeCurrentFrame(); + }); + } + + // AJOUT : Stoppe le timer de détection + void _stopAlignmentDetection() { + _detectionTimer?.cancel(); + _detectionTimer = null; + _alignmentStatus = null; + } + + // AJOUT : Capture une frame et analyse le parallélisme via OpenCV + Future _analyzeCurrentFrame() async { + // Verrou : on n'analyse pas si une analyse est déjà en cours + if (_isAnalyzingFrame) return; + if (_cameraController == null || !_cameraController!.value.isInitialized) return; + if (!_showLiveCamera) return; + + _isAnalyzingFrame = true; + try { + // 1. Capture d'une frame temporaire (sans déclencher la prise de photo) + final XFile frame = await _cameraController!.takePicture(); + + // 2. Analyse OpenCV de la frame capturée + final result = await _opencvService.detectTarget(frame.path); + + // 3. Nettoyage du fichier temporaire + try { + await File(frame.path).delete(); + } catch (_) {} + + // 4. Mise à jour du statut d'alignement si on est toujours sur l'écran caméra + if (mounted && _showLiveCamera) { + setState(() { + if (!result.success) { + // Aucune cible détectée → statut indéfini + _alignmentStatus = null; + } else { + // PARALLÉLISME : On vérifie que le centre détecté est proche du centre de l'image + // et que le rayon est suffisamment grand (cible bien cadrée) + // Un centre entre 0.3 et 0.7 en X et Y = bien centré = parallélisme OK + final bool isCentered = + result.centerX > 0.25 && result.centerX < 0.75 && + result.centerY > 0.25 && result.centerY < 0.75; + final bool isSizedCorrectly = result.radius > 0.15; + _alignmentStatus = isCentered && isSizedCorrectly; + } + }); + } + } catch (e) { + debugPrint('Erreur analyse frame: $e'); + } finally { + _isAnalyzingFrame = false; + } + } + Future _handleGallerySelection() async { PermissionStatus status; @@ -214,6 +296,19 @@ class _CaptureScreenState extends State with SingleTickerProvider // INTERFACE CORRIGÉE : Cadre fixe et cible ronde verte au centre // INTERFACE : Restauration de TON ancienne cible centrale verte d'origine Widget _buildLiveCameraView() { + // AJOUT : Couleur du cadre selon le statut d'alignement + // null = vert (pas encore analysé), true = vert (bien aligné), false = orange (mal aligné) + final Color frameColor = _alignmentStatus == false + ? Colors.orange + : const Color(0xFF00FF00); + + // AJOUT : Message d'aide selon le statut d'alignement + final String alignmentMessage = _alignmentStatus == null + ? 'ALIGNEZ LA CIBLE DANS LE CADRE' + : _alignmentStatus == true + ? 'CIBLE BIEN ALIGNÉE — PRÊT À TIRER' + : 'REPOSITIONNEZ — ANGLE TROP FORT'; + return Scaffold( backgroundColor: Colors.black, body: Stack( @@ -234,7 +329,7 @@ class _CaptureScreenState extends State with SingleTickerProvider height: MediaQuery.of(context).size.width * 0.85, decoration: BoxDecoration( border: Border.all( - color: const Color(0xFF00FF00), // Grand cadre extérieur vert fluo + color: frameColor, // MODIFIÉ : Couleur dynamique selon l'alignement width: 2.0, ), borderRadius: BorderRadius.circular(16), @@ -242,10 +337,10 @@ class _CaptureScreenState extends State with SingleTickerProvider child: Stack( children: [ // SÉCURITÉ : On réintègre les 4 coins renforcés sur les côtés ! - _buildCameraCorner(TopLeft: true), - _buildCameraCorner(TopRight: true), - _buildCameraCorner(BottomLeft: true), - _buildCameraCorner(BottomRight: true), + _buildCameraCorner(TopLeft: true, color: frameColor), + _buildCameraCorner(TopRight: true, color: frameColor), + _buildCameraCorner(BottomLeft: true, color: frameColor), + _buildCameraCorner(BottomRight: true, color: frameColor), // TA MIRE CENTRALE HISTORIQUE FINALE Center( @@ -255,7 +350,7 @@ class _CaptureScreenState extends State with SingleTickerProvider decoration: BoxDecoration( shape: BoxShape.circle, border: Border.all( - color: const Color(0xFF00FF00), + color: frameColor, // MODIFIÉ : Couleur dynamique width: 1.2, // Ligne fine ), ), @@ -266,7 +361,7 @@ class _CaptureScreenState extends State with SingleTickerProvider child: Container( width: 20, height: 1.2, - color: const Color(0xFF00FF00), + color: frameColor, // MODIFIÉ : Couleur dynamique ), ), // Trait vertical fin confiné @@ -274,7 +369,7 @@ class _CaptureScreenState extends State with SingleTickerProvider child: Container( width: 1.2, height: 20, - color: const Color(0xFF00FF00), + color: frameColor, // MODIFIÉ : Couleur dynamique ), ), ], @@ -295,6 +390,8 @@ class _CaptureScreenState extends State with SingleTickerProvider child: IconButton( icon: const Icon(Icons.arrow_back, color: Colors.white), onPressed: () { + // AJOUT : On stoppe la détection quand on quitte la caméra + _stopAlignmentDetection(); setState(() { _showLiveCamera = false; }); @@ -303,7 +400,7 @@ class _CaptureScreenState extends State with SingleTickerProvider ), ), - // 4. Consigne de scan textuelle + // 4. Consigne de scan textuelle — MODIFIÉ : Message dynamique selon l'alignement Positioned( top: 50, left: 0, @@ -315,10 +412,10 @@ class _CaptureScreenState extends State with SingleTickerProvider color: Colors.black.withValues(alpha: 0.7), borderRadius: BorderRadius.circular(20), ), - child: const Text( - 'ALIGNEZ LA CIBLE DANS LE CADRE', + child: Text( + alignmentMessage, style: TextStyle( - color: Color(0xFF00FF00), + color: frameColor, // MODIFIÉ : Couleur dynamique fontWeight: FontWeight.bold, fontSize: 12, letterSpacing: 1.2, @@ -328,6 +425,21 @@ class _CaptureScreenState extends State with SingleTickerProvider ), ), + // AJOUT : Indicateur visuel de l'analyse en cours (petit point clignotant) + if (_isAnalyzingFrame) + Positioned( + top: 45, + right: 20, + child: Container( + width: 10, + height: 10, + decoration: const BoxDecoration( + color: Colors.white54, + shape: BoxShape.circle, + ), + ), + ), + // 5. Bouton déclencheur Manuel en bas Positioned( bottom: 40, @@ -362,11 +474,13 @@ class _CaptureScreenState extends State with SingleTickerProvider ); } + // MODIFIÉ : Ajout du paramètre color pour la couleur dynamique des coins Widget _buildCameraCorner({ bool TopLeft = false, bool TopRight = false, bool BottomLeft = false, bool BottomRight = false, + Color color = const Color(0xFF00FF00), // AJOUT : Couleur dynamique }) { return Positioned( top: (TopLeft || TopRight) ? 10 : null, @@ -378,10 +492,10 @@ class _CaptureScreenState extends State with SingleTickerProvider height: 20, decoration: BoxDecoration( border: Border( - top: (TopLeft || TopRight) ? const BorderSide(color: Color(0xFF00FF00), width: 4) : BorderSide.none, - bottom: (BottomLeft || BottomRight) ? const BorderSide(color: Color(0xFF00FF00), width: 4) : BorderSide.none, - left: (TopLeft || BottomLeft) ? const BorderSide(color: Color(0xFF00FF00), width: 4) : BorderSide.none, - right: (TopRight || BottomRight) ? const BorderSide(color: Color(0xFF00FF00), width: 4) : BorderSide.none, + top: (TopLeft || TopRight) ? BorderSide(color: color, width: 4) : BorderSide.none, + bottom: (BottomLeft || BottomRight) ? BorderSide(color: color, width: 4) : BorderSide.none, + left: (TopLeft || BottomLeft) ? BorderSide(color: color, width: 4) : BorderSide.none, + right: (TopRight || BottomRight) ? BorderSide(color: color, width: 4) : BorderSide.none, ), ), ), @@ -394,6 +508,17 @@ class _CaptureScreenState extends State with SingleTickerProvider setState(() => _isLoading = true); try { + // CORRECTIF ZOOM : On remet le zoom à 1.0 avant la capture pour ne pas + // propager le zoom optique dans les écrans suivants + try { + await _cameraController!.setZoomLevel(1.0); + } catch (_) { + // Certains appareils ne supportent pas setZoomLevel, on ignore l'erreur + } + + // AJOUT : On stoppe la détection périodique avant de prendre la photo + _stopAlignmentDetection(); + // 1. On prend la photo brute normale (sans y toucher) final XFile photo = await _cameraController!.takePicture(); @@ -484,8 +609,8 @@ class _CaptureScreenState extends State with SingleTickerProvider } catch (e) { debugPrint('Erreur galerie: $e'); } finally { - if (mounted) setState(() => _isLoading = false); - } + if (mounted) setState(() => _isLoading = false); + } } void _analyzeImage() { diff --git a/lib/services/image_crop_service.dart b/lib/services/image_crop_service.dart index 1ee7402b..cd2e34eb 100644 --- a/lib/services/image_crop_service.dart +++ b/lib/services/image_crop_service.dart @@ -1,27 +1,14 @@ -/// Service de recadrage d'images. -/// -/// Permet de recadrer une image en format carré (1:1) et de la sauvegarder -/// dans un fichier temporaire pour utilisation ultérieure. -library; - +import 'dart:isolate'; // AJOUT import 'dart:io'; import 'dart:math' as math; import 'package:image/image.dart' as img; import 'package:path_provider/path_provider.dart'; import 'package:uuid/uuid.dart'; -/// Représente une zone de recadrage normalisée (0.0 à 1.0) class CropRect { - /// Position X du coin supérieur gauche (0.0 à 1.0) final double x; - - /// Position Y du coin supérieur gauche (0.0 à 1.0) final double y; - - /// Largeur de la zone (0.0 à 1.0) final double width; - - /// Hauteur de la zone (0.0 à 1.0) final double height; const CropRect({ @@ -35,84 +22,110 @@ class CropRect { String toString() => 'CropRect(x: $x, y: $y, w: $width, h: $height)'; } -/// Service pour recadrer les images +// AJOUT : Paramètres passés à l'Isolate (tout doit être sérialisable) +class _CropParams { + final String sourcePath; + final double cropX; + final double cropY; + final double cropWidth; + final double cropHeight; + final double rotationDegrees; + final int outputSize; + final String outputPath; + + _CropParams({ + required this.sourcePath, + required this.cropX, + required this.cropY, + required this.cropWidth, + required this.cropHeight, + required this.rotationDegrees, + required this.outputSize, + required this.outputPath, + }); +} + +// AJOUT : Fonction statique exécutée dans l'Isolate (doit être top-level ou static) +void _cropIsolateEntry(_CropParams params) { + final file = File(params.sourcePath); + final bytes = file.readAsBytesSync(); + img.Image? originalImage = img.decodeImage(bytes); + + if (originalImage == null) { + throw Exception('Impossible de décoder l\'image: ${params.sourcePath}'); + } + + // Rotation si nécessaire — LINEAR au lieu de CUBIC pour la vitesse + if (params.rotationDegrees != 0.0) { + originalImage = img.copyRotate( + originalImage, + angle: params.rotationDegrees, + interpolation: img.Interpolation.linear, // OPTIMISATION : linear >> cubic en vitesse + ); + } + + // Recadrage + final srcX = (params.cropX * originalImage.width).round(); + final srcY = (params.cropY * originalImage.height).round(); + final srcWidth = (params.cropWidth * originalImage.width).round(); + final srcHeight = (params.cropHeight * originalImage.height).round(); + + final clampedX = srcX.clamp(0, originalImage.width - 1); + final clampedY = srcY.clamp(0, originalImage.height - 1); + final clampedWidth = math.min(srcWidth, originalImage.width - clampedX); + final clampedHeight = math.min(srcHeight, originalImage.height - clampedY); + + img.Image cropped = img.copyCrop( + originalImage, + x: clampedX, + y: clampedY, + width: clampedWidth, + height: clampedHeight, + ); + + // Redimensionnement — LINEAR au lieu de CUBIC + if (cropped.width != params.outputSize || cropped.height != params.outputSize) { + cropped = img.copyResize( + cropped, + width: params.outputSize, + height: params.outputSize, + interpolation: img.Interpolation.linear, // OPTIMISATION : linear >> cubic en vitesse + ); + } + + // Encodage JPEG qualité 85 au lieu de 90 (gain de vitesse non négligeable) + final outputFile = File(params.outputPath); + outputFile.writeAsBytesSync(img.encodeJpg(cropped, quality: 85)); +} + class ImageCropService { final Uuid _uuid = const Uuid(); - /// Taille de sortie maximale pour les images recadrées static const int maxOutputSize = 1024; - /// Recadre une image en format carré en prenant en compte l'angle de rotation. - /// - /// [sourcePath] - Chemin vers l'image source - /// [cropRect] - Zone de recadrage normalisée (0.0 à 1.0) - /// [rotationDegrees] - L'angle appliqué à la jauge haute précision du CropScreen - /// [outputSize] - Taille de sortie en pixels (carré) - /// - /// Retourne le chemin vers l'image recadrée dans le dossier temporaire Future cropToSquare( String sourcePath, CropRect cropRect, { - double rotationDegrees = 0.0, // FIX : On intercepte la rotation ici ! + double rotationDegrees = 0.0, int outputSize = maxOutputSize, }) async { - // Charger l'image source - final file = File(sourcePath); - final bytes = await file.readAsBytes(); - img.Image? originalImage = img.decodeImage(bytes); + final tempDir = await getTemporaryDirectory(); + final outputPath = '${tempDir.path}/cropped_${_uuid.v4()}.jpg'; - if (originalImage == null) { - throw Exception('Impossible de décoder l\'image: $sourcePath'); - } - - // FIX CRITIQUE : Si l'utilisateur a pivoté l'image, on applique d'abord la rotation - // aux pixels bruts pour que le découpage rectiligne qui suit tape au bon endroit. - if (rotationDegrees != 0.0) { - originalImage = img.copyRotate( - originalImage, - angle: rotationDegrees, - interpolation: img.Interpolation.cubic, - ); - } - - // Calculer les coordonnées en pixels sur la géométrie de l'image redressée - final srcX = (cropRect.x * originalImage.width).round(); - final srcY = (cropRect.y * originalImage.height).round(); - final srcWidth = (cropRect.width * originalImage.width).round(); - final srcHeight = (cropRect.height * originalImage.height).round(); - - // S'assurer que les dimensions sont valides - final clampedX = srcX.clamp(0, originalImage.width - 1); - final clampedY = srcY.clamp(0, originalImage.height - 1); - final clampedWidth = math.min(srcWidth, originalImage.width - clampedX); - final clampedHeight = math.min(srcHeight, originalImage.height - clampedY); - - // Recadrer l'image redressée - img.Image cropped = img.copyCrop( - originalImage, - x: clampedX, - y: clampedY, - width: clampedWidth, - height: clampedHeight, + final params = _CropParams( + sourcePath: sourcePath, + cropX: cropRect.x, + cropY: cropRect.y, + cropWidth: cropRect.width, + cropHeight: cropRect.height, + rotationDegrees: rotationDegrees, + outputSize: outputSize, + outputPath: outputPath, ); - // Redimensionner à la taille de sortie si nécessaire - if (cropped.width != outputSize || cropped.height != outputSize) { - cropped = img.copyResize( - cropped, - width: outputSize, - height: outputSize, - interpolation: img.Interpolation.cubic, - ); - } - - // Sauvegarder dans le dossier temporaire - final tempDir = await getTemporaryDirectory(); - final fileName = 'cropped_${_uuid.v4()}.jpg'; - final outputPath = '${tempDir.path}/$fileName'; - - final outputFile = File(outputPath); - await outputFile.writeAsBytes(img.encodeJpg(cropped, quality: 90)); + // OPTIMISATION CLEF : Tout le traitement lourd dans un Isolate séparé + // → le thread UI reste fluide pendant le calcul + await Isolate.run(() => _cropIsolateEntry(params)); return outputPath; } @@ -157,7 +170,6 @@ class ImageCropService { /// Coupe automatiquement une photo brute de l'appareil photo en carré /// calé sur la position haute du viseur de l'écran. - /// Coupe une photo brute selon les proportions exactes du viseur de l'écran. Future cropRawCameraToSquare(String sourcePath) async { final file = File(sourcePath); final bytes = await file.readAsBytes(); @@ -165,19 +177,14 @@ class ImageCropService { if (originalImage == null) return sourcePath; - // 1. Déterminer les dimensions réelles (gestion automatique de la rotation du capteur) final int imgW = originalImage.width; final int imgH = originalImage.height; - // 2. Ton cadre à l'écran fait 85% de la largeur du viewport (0.85). - // On calcule la taille du carré par rapport à la plus petite dimension. final int cropSizePixels = (math.min(imgW, imgH) * 0.85).round(); - // 3. Calcul du centre parfait pour le x et le y final int x = ((imgW - cropSizePixels) / 2).round(); final int y = ((imgH - cropSizePixels) / 2).round(); - // 4. Découpe physique au pixel près final squareImage = img.copyCrop( originalImage, x: x.clamp(0, imgW - 1), @@ -186,10 +193,9 @@ class ImageCropService { height: cropSizePixels, ); - // 5. Sauvegarde final tempDir = await getTemporaryDirectory(); final outputPath = '${tempDir.path}/camera_square_${DateTime.now().millisecondsSinceEpoch}.jpg'; - await File(outputPath).writeAsBytes(img.encodeJpg(squareImage, quality: 90)); + await File(outputPath).writeAsBytes(img.encodeJpg(squareImage, quality: 85)); return outputPath; }