feat: construction du service parallelisme

This commit is contained in:
qguillaume
2026-06-05 19:47:18 +02:00
parent 3ae9ceebc1
commit 9cb37fc303
4 changed files with 416 additions and 158 deletions

View File

@@ -1,11 +1,11 @@
import 'dart:io';
import 'dart:math' as math;
import 'dart:async'; // AJOUT : Pour le Timer de détection périodique
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:camera/camera.dart'; // AJOUT : Pour piloter le flux caméra en direct
import 'package:camera/camera.dart';
import 'package:provider/provider.dart';
import '../../core/constants/app_constants.dart';
@@ -15,9 +15,11 @@ 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
import '../../services/opencv_target_service.dart';
import '../../services/parallelism_service.dart'; // NOUVEAU
import 'package:image/image.dart' as img;
import 'package:path_provider/path_provider.dart';
class CaptureScreen extends StatefulWidget {
const CaptureScreen({super.key});
@@ -25,34 +27,43 @@ class CaptureScreen extends StatefulWidget {
State<CaptureScreen> createState() => _CaptureScreenState();
}
class _CaptureScreenState extends State<CaptureScreen> with SingleTickerProviderStateMixin {
class _CaptureScreenState extends State<CaptureScreen>
with SingleTickerProviderStateMixin {
final ImagePicker _picker = ImagePicker();
final ImageCropService _cropService = ImageCropService();
final TargetType _selectedType = TargetType.concentric;
final OpenCVTargetService _opencvService = OpenCVTargetService(); // AJOUT : Service de détection
final OpenCVTargetService _opencvService = OpenCVTargetService();
// NOUVEAU : Service IMU de parallélisme
final ParallelismService _parallelismService = ParallelismService(
thresholdDegrees: 5.0, // ±5° max autorisés
);
String? _selectedImagePath;
bool _isLoading = false;
// AJOUT : Variables de gestion du flux caméra en direct
// Caméra live
CameraController? _cameraController;
List<CameraDescription>? _cameras;
bool _isCameraInitialized = false;
bool _showLiveCamera = false;
// AJOUT : Animation pour l'effet "pulsation de détection" du cadre vert
// Animation pulsation du viseur
late AnimationController _scanAnimationController;
late Animation<double> _scanAnimation;
// AJOUT : Variables pour le parallélisme
// _alignmentStatus : null = pas encore analysé, true = bien aligné, false = mal aligné
// Détection OpenCV (inchangée)
bool? _alignmentStatus;
Timer? _detectionTimer;
bool _isAnalyzingFrame = false; // Verrou pour éviter les appels OpenCV simultanés
bool _isAnalyzingFrame = false;
// NOUVEAU : Données IMU en temps réel
ParallelismData? _parallelismData;
StreamSubscription<ParallelismData>? _parallelismSubscription;
@override
void initState() {
super.initState();
// Initialisation de l'animation du viseur vert (effet pulsation)
_scanAnimationController = AnimationController(
duration: const Duration(seconds: 2),
vsync: this,
@@ -67,11 +78,72 @@ class _CaptureScreenState extends State<CaptureScreen> with SingleTickerProvider
void dispose() {
_cameraController?.dispose();
_scanAnimationController.dispose();
_detectionTimer?.cancel(); // AJOUT : On stoppe le timer proprement
_detectionTimer?.cancel();
_parallelismSubscription?.cancel(); // NOUVEAU
_parallelismService.dispose(); // NOUVEAU
super.dispose();
}
// AJOUT : Initialisation de la caméra embarquée
// ─────────────────────────────────────────────────────────────────────────
// NOUVEAU : Démarre l'écoute IMU et met à jour l'UI en temps réel
// ─────────────────────────────────────────────────────────────────────────
void _startParallelismDetection() {
_parallelismSubscription?.cancel();
_parallelismService.start();
_parallelismSubscription = _parallelismService.stream.listen((data) {
if (mounted && _showLiveCamera) {
setState(() {
_parallelismData = data;
});
}
});
}
void _stopParallelismDetection() {
_parallelismSubscription?.cancel();
_parallelismSubscription = null;
_parallelismService.stop();
_parallelismData = null;
}
// ─────────────────────────────────────────────────────────────────────────
// NOUVEAU : Calcule la couleur et le message depuis les données IMU
// ─────────────────────────────────────────────────────────────────────────
/// Couleur du cadre selon le parallélisme détecté par l'IMU.
Color get _frameColor {
if (_parallelismData == null) return const Color(0xFF00FF00);
return _parallelismData!.isAligned ? const Color(0xFF00FF00) : Colors.orange;
}
/// Message d'aide selon le parallélisme détecté.
String get _alignmentMessage {
if (_parallelismData == null) {
return 'ALIGNEZ LA CIBLE DANS LE CADRE';
}
if (_parallelismData!.status == ParallelismStatus.aligned) {
return 'PARALLÈLE OK — PRÊT À PHOTOGRAPHIER';
}
// Message directif selon l'axe le plus dévié
final double pitch = _parallelismData!.pitchDegrees;
final double roll = _parallelismData!.rollDegrees;
if (pitch.abs() >= roll.abs()) {
return pitch > 0
? 'INCLINEZ VERS L\'AVANT (${pitch.toStringAsFixed(1)}°)'
: 'INCLINEZ VERS L\'ARRIÈRE (${(-pitch).toStringAsFixed(1)}°)';
} else {
return roll > 0
? 'INCLINEZ VERS LA GAUCHE (${roll.toStringAsFixed(1)}°)'
: 'INCLINEZ VERS LA DROITE (${(-roll).toStringAsFixed(1)}°)';
}
}
// ─────────────────────────────────────────────────────────────────────────
// Initialisation de la caméra (inchangée, sauf ajout IMU)
// ─────────────────────────────────────────────────────────────────────────
Future<void> _initLiveCamera() async {
final status = await Permission.camera.request();
if (!status.isGranted) {
@@ -85,8 +157,6 @@ class _CaptureScreenState extends State<CaptureScreen> 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;
@@ -97,22 +167,22 @@ class _CaptureScreenState extends State<CaptureScreen> with SingleTickerProvider
if (_cameras != null && _cameras!.isNotEmpty) {
_cameraController = CameraController(
_cameras![0],
ResolutionPreset.max, // Force le 4:3 natif au maximum du capteur
ResolutionPreset.max,
enableAudio: false,
imageFormatGroup: ImageFormatGroup.jpeg,
);
// INITIALISATION UNIQUE ET PROPRE :
await _cameraController!.initialize();
setState(() {
_isCameraInitialized = true;
_showLiveCamera = true;
_alignmentStatus = null; // AJOUT : Reset du statut d'alignement à chaque ouverture
_alignmentStatus = null;
_parallelismData = null; // NOUVEAU : reset IMU
});
// AJOUT : Démarrage du timer de détection périodique (toutes les 2 secondes)
_startAlignmentDetection();
_startParallelismDetection(); // NOUVEAU
}
} catch (e) {
debugPrint('Erreur caméra: $e');
@@ -121,12 +191,13 @@ class _CaptureScreenState extends State<CaptureScreen> with SingleTickerProvider
}
}
// ─────────────────────────────────────────────────────────────────────────
// Détection OpenCV périodique (inchangée)
// ─────────────────────────────────────────────────────────────────────────
void _startAlignmentDetection() {
_detectionTimer?.cancel();
_detectionTimer = null;
// OPTIMISATION : startImageStream au lieu de takePicture
// On analyse 1 frame toutes les 3 secondes via le stream natif
DateTime? _lastAnalysis;
_cameraController!.startImageStream((CameraImage cameraImage) async {
@@ -138,24 +209,22 @@ class _CaptureScreenState extends State<CaptureScreen> with SingleTickerProvider
_isAnalyzingFrame = true;
try {
// Convertir la CameraImage en fichier temporaire pour OpenCV
final tempDir = await getTemporaryDirectory();
final tempPath =
'${tempDir.path}/frame_analysis.jpg';
final tempPath = '${tempDir.path}/frame_analysis.jpg';
// Conversion YUV420 → JPEG via le package image
final img.Image? converted = _convertCameraImage(cameraImage);
if (converted == null) {
_isAnalyzingFrame = false;
return;
}
await File(tempPath)
.writeAsBytes(img.encodeJpg(converted, quality: 60));
await File(tempPath).writeAsBytes(img.encodeJpg(converted, quality: 60));
final result = await _opencvService.detectTarget(tempPath);
try { await File(tempPath).delete(); } catch (_) {}
try {
await File(tempPath).delete();
} catch (_) {}
if (mounted && _showLiveCamera) {
setState(() {
@@ -178,11 +247,9 @@ class _CaptureScreenState extends State<CaptureScreen> with SingleTickerProvider
});
}
// AJOUT : Stoppe le timer de détection
void _stopAlignmentDetection() {
_detectionTimer?.cancel();
_detectionTimer = null;
// AJOUT : Stopper le stream proprement
try {
if (_cameraController != null &&
_cameraController!.value.isStreamingImages) {
@@ -192,14 +259,10 @@ class _CaptureScreenState extends State<CaptureScreen> with SingleTickerProvider
_alignmentStatus = null;
}
// AJOUT : Conversion CameraImage (YUV420) vers img.Image
img.Image? _convertCameraImage(CameraImage cameraImage) {
try {
final int width = cameraImage.width;
final int height = cameraImage.height;
// Réduction de résolution pour l'analyse : on prend 1 pixel sur 2
// (suffisant pour détecter les cercles, beaucoup plus rapide)
final int targetW = width ~/ 2;
final int targetH = height ~/ 2;
@@ -215,16 +278,17 @@ class _CaptureScreenState extends State<CaptureScreen> with SingleTickerProvider
final int srcY = y * 2;
final int yIndex = srcY * yPlane.bytesPerRow + srcX;
final int uvIndex =
(srcY ~/ 2) * uPlane.bytesPerRow + (srcX ~/ 2) * uPlane.bytesPerPixel!;
final int uvIndex = (srcY ~/ 2) * uPlane.bytesPerRow +
(srcX ~/ 2) * uPlane.bytesPerPixel!;
final int yVal = yPlane.bytes[yIndex];
final int uVal = uPlane.bytes[uvIndex] - 128;
final int vVal = vPlane.bytes[uvIndex] - 128;
// Conversion YUV → RGB
final int r = (yVal + 1.402 * vVal).clamp(0, 255).toInt();
final int g = (yVal - 0.344136 * uVal - 0.714136 * vVal).clamp(0, 255).toInt();
final int g = (yVal - 0.344136 * uVal - 0.714136 * vVal)
.clamp(0, 255)
.toInt();
final int b = (yVal + 1.772 * uVal).clamp(0, 255).toInt();
image.setPixelRgb(x, y, r, g, b);
@@ -238,63 +302,18 @@ class _CaptureScreenState extends State<CaptureScreen> with SingleTickerProvider
}
}
// AJOUT : Capture une frame et analyse le parallélisme via OpenCV
Future<void> _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;
}*/
}
// ─────────────────────────────────────────────────────────────────────────
// Galerie (inchangée)
// ─────────────────────────────────────────────────────────────────────────
Future<void> _handleGallerySelection() async {
PermissionStatus status;
if (Platform.isAndroid) {
final deviceInfo = DeviceInfoPlugin();
final androidInfo = await deviceInfo.androidInfo;
if (androidInfo.version.sdkInt >= 33) {
status = await Permission.photos.request();
} else {
status = await Permission.storage.request();
}
status = androidInfo.version.sdkInt >= 33
? await Permission.photos.request()
: await Permission.storage.request();
} else {
status = await Permission.photos.request();
}
@@ -336,6 +355,9 @@ class _CaptureScreenState extends State<CaptureScreen> with SingleTickerProvider
);
}
// ─────────────────────────────────────────────────────────────────────────
// Build principal
// ─────────────────────────────────────────────────────────────────────────
@override
Widget build(BuildContext context) {
final sessionProvider = context.watch<SessionProvider>();
@@ -343,8 +365,9 @@ class _CaptureScreenState extends State<CaptureScreen> with SingleTickerProvider
? 'Cible ${sessionProvider.targetCount + 1}'
: 'Source';
// AJOUT : Si l'utilisateur clique sur scanner, on affiche la caméra plein écran avec l'overlay vert
if (_showLiveCamera && _cameraController != null && _cameraController!.value.isInitialized) {
if (_showLiveCamera &&
_cameraController != null &&
_cameraController!.value.isInitialized) {
return _buildLiveCameraView();
}
@@ -356,7 +379,6 @@ class _CaptureScreenState extends State<CaptureScreen> with SingleTickerProvider
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: AppConstants.largePadding),
_buildSectionTitle('Source de l\'Image'),
const SizedBox(height: 12),
Row(
@@ -365,7 +387,7 @@ class _CaptureScreenState extends State<CaptureScreen> with SingleTickerProvider
child: ImageSourceButton(
icon: Icons.camera_alt,
label: 'Scanner',
onPressed: _isLoading ? null : _initLiveCamera, // Ouvre notre caméra intégrée
onPressed: _isLoading ? null : _initLiveCamera,
),
),
const SizedBox(width: 12),
@@ -379,7 +401,6 @@ class _CaptureScreenState extends State<CaptureScreen> with SingleTickerProvider
],
),
const SizedBox(height: AppConstants.largePadding),
if (_isLoading)
const Center(
child: Padding(
@@ -395,84 +416,65 @@ class _CaptureScreenState extends State<CaptureScreen> with SingleTickerProvider
);
}
// AJOUT : Interface de la caméra live avec l'assistant de cadrage vert
// INTERFACE CORRIGÉE : Cadre fixe et cible ronde verte au centre
// INTERFACE : Restauration de TON ancienne cible centrale verte d'origine
// ─────────────────────────────────────────────────────────────────────────
// Vue caméra live — MODIFIÉE pour utiliser les données IMU
// ─────────────────────────────────────────────────────────────────────────
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';
// Priorité : IMU (parallélisme physique) prime sur OpenCV (centrage)
// Si l'IMU dit "mal aligné" → orange, sinon → vert
final Color frameColor = _frameColor;
final String alignmentMessage = _alignmentMessage;
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
fit: StackFit.expand,
children: [
// 1. Le flux vidéo de la caméra
// 1. Flux vidéo caméra
Center(
child: AspectRatio(
aspectRatio: 3 / 4, // Aligné sur le format natif du capteur photo
aspectRatio: 3 / 4,
child: CameraPreview(_cameraController!),
),
),
// 2. L'ASSISTANT DE CADRAGE VIRTUEL (Avec le grand cadre complet et la petite cible)
// 2. Cadre de visée avec coins et mire centrale
Center(
child: Container(
width: MediaQuery.of(context).size.width * 0.85,
height: MediaQuery.of(context).size.width * 0.85,
decoration: BoxDecoration(
border: Border.all(
color: frameColor, // MODIFIÉ : Couleur dynamique selon l'alignement
width: 2.0,
),
border: Border.all(color: frameColor, width: 2.0),
borderRadius: BorderRadius.circular(16),
),
child: Stack(
children: [
// SÉCURITÉ : On réintègre les 4 coins renforcés sur les côtés !
_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(
child: Container(
width: 24, // Diamètre global de la petite cible
width: 24,
height: 24,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: frameColor, // MODIFIÉ : Couleur dynamique
width: 1.2, // Ligne fine
),
border: Border.all(color: frameColor, width: 1.2),
),
child: Stack(
children: [
// Trait horizontal fin confiné
Center(
child: Container(
width: 20,
height: 1.2,
color: frameColor, // MODIFIÉ : Couleur dynamique
color: frameColor,
),
),
// Trait vertical fin confiné
Center(
child: Container(
width: 1.2,
height: 20,
color: frameColor, // MODIFIÉ : Couleur dynamique
color: frameColor,
),
),
],
@@ -484,7 +486,7 @@ class _CaptureScreenState extends State<CaptureScreen> with SingleTickerProvider
),
),
// 3. Barre supérieure avec bouton Retour
// 3. Bouton retour
Positioned(
top: 40,
left: 20,
@@ -493,8 +495,8 @@ class _CaptureScreenState extends State<CaptureScreen> 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();
_stopParallelismDetection(); // NOUVEAU
setState(() {
_showLiveCamera = false;
});
@@ -503,7 +505,7 @@ class _CaptureScreenState extends State<CaptureScreen> with SingleTickerProvider
),
),
// 4. Consigne de scan textuelle — MODIFIÉ : Message dynamique selon l'alignement
// 4. Message d'alignement principal (texte dynamique IMU)
Positioned(
top: 50,
left: 0,
@@ -518,7 +520,7 @@ class _CaptureScreenState extends State<CaptureScreen> with SingleTickerProvider
child: Text(
alignmentMessage,
style: TextStyle(
color: frameColor, // MODIFIÉ : Couleur dynamique
color: frameColor,
fontWeight: FontWeight.bold,
fontSize: 12,
letterSpacing: 1.2,
@@ -528,7 +530,16 @@ class _CaptureScreenState extends State<CaptureScreen> with SingleTickerProvider
),
),
// AJOUT : Indicateur visuel de l'analyse en cours (petit point clignotant)
// NOUVEAU : Indicateur visuel des angles en bas à gauche
if (_parallelismData != null &&
_parallelismData!.status != ParallelismStatus.unknown)
Positioned(
bottom: 140,
left: 16,
child: _buildAngleIndicator(_parallelismData!),
),
// Indicateur d'analyse en cours
if (_isAnalyzingFrame)
Positioned(
top: 45,
@@ -543,7 +554,7 @@ class _CaptureScreenState extends State<CaptureScreen> with SingleTickerProvider
),
),
// 5. Bouton déclencheur Manuel en bas
// 5. Bouton déclencheur
Positioned(
bottom: 40,
left: 0,
@@ -577,13 +588,78 @@ class _CaptureScreenState extends State<CaptureScreen> with SingleTickerProvider
);
}
// MODIFIÉ : Ajout du paramètre color pour la couleur dynamique des coins
// ─────────────────────────────────────────────────────────────────────────
// NOUVEAU : Widget affichant les angles pitch/roll en temps réel
// ─────────────────────────────────────────────────────────────────────────
Widget _buildAngleIndicator(ParallelismData data) {
final Color color = data.isAligned ? const Color(0xFF00FF00) : Colors.orange;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: color.withValues(alpha: 0.5), width: 1),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
_buildAngleRow(
label: 'Pitch',
value: data.pitchDegrees,
color: color,
),
const SizedBox(height: 2),
_buildAngleRow(
label: 'Roll ',
value: data.rollDegrees,
color: color,
),
],
),
);
}
Widget _buildAngleRow({
required String label,
required double value,
required Color color,
}) {
final String sign = value >= 0 ? '+' : '';
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'$label: ',
style: const TextStyle(
color: Colors.white60,
fontSize: 11,
fontFamily: 'monospace',
),
),
Text(
'$sign${value.toStringAsFixed(1)}°',
style: TextStyle(
color: color,
fontSize: 11,
fontWeight: FontWeight.bold,
fontFamily: 'monospace',
),
),
],
);
}
// ─────────────────────────────────────────────────────────────────────────
// Coins du cadre (inchangés)
// ─────────────────────────────────────────────────────────────────────────
Widget _buildCameraCorner({
bool TopLeft = false,
bool TopRight = false,
bool BottomLeft = false,
bool BottomRight = false,
Color color = const Color(0xFF00FF00), // AJOUT : Couleur dynamique
Color color = const Color(0xFF00FF00),
}) {
return Positioned(
top: (TopLeft || TopRight) ? 10 : null,
@@ -595,34 +671,41 @@ class _CaptureScreenState extends State<CaptureScreen> with SingleTickerProvider
height: 20,
decoration: BoxDecoration(
border: Border(
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,
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,
),
),
),
);
}
// AJOUT : Capture manuelle via notre bouton déclencheur
// ─────────────────────────────────────────────────────────────────────────
// Capture manuelle (inchangée, sauf ajout stop IMU)
// ─────────────────────────────────────────────────────────────────────────
Future<void> _takePictureManually() async {
if (_cameraController == null || !_cameraController!.value.isInitialized) return;
if (_cameraController == null || !_cameraController!.value.isInitialized) {
return;
}
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
}
} catch (_) {}
// AJOUT : On stoppe la détection périodique avant de prendre la photo
_stopAlignmentDetection();
_stopParallelismDetection(); // NOUVEAU
// 1. On prend la photo brute normale (sans y toucher)
final XFile photo = await _cameraController!.takePicture();
setState(() {
@@ -630,7 +713,6 @@ class _CaptureScreenState extends State<CaptureScreen> with SingleTickerProvider
_showLiveCamera = false;
});
// 2. On l'envoie directement à l'analyse
_analyzeImage();
} catch (e) {
debugPrint('Erreur lors du clic photo: $e');
@@ -639,12 +721,16 @@ class _CaptureScreenState extends State<CaptureScreen> with SingleTickerProvider
}
}
// ─────────────────────────────────────────────────────────────────────────
// Helpers UI (inchangés)
// ─────────────────────────────────────────────────────────────────────────
Widget _buildSectionTitle(String title) {
return Text(
title,
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(fontWeight: FontWeight.bold),
);
}
@@ -658,9 +744,10 @@ class _CaptureScreenState extends State<CaptureScreen> with SingleTickerProvider
const SizedBox(height: 12),
Text(
'Conseils pour une bonne analyse',
style: Theme.of(
context,
).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold),
style: Theme.of(context)
.textTheme
.titleSmall
?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 12),
_buildGuideItem(
@@ -693,7 +780,6 @@ class _CaptureScreenState extends State<CaptureScreen> with SingleTickerProvider
);
}
// Capture depuis la galerie d'images
Future<void> _captureImageFromGallery() async {
setState(() => _isLoading = true);
try {

View File

@@ -0,0 +1,156 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:sensors_plus/sensors_plus.dart';
/// Statut de parallélisme retourné en temps réel.
enum ParallelismStatus {
/// Pas encore de données capteur disponibles.
unknown,
/// L'appareil est bien parallèle à la cible (pitch et roll dans les seuils).
aligned,
/// L'appareil est trop incliné — l'utilisateur doit repositionner.
misaligned,
}
/// Données de parallélisme calculées à chaque frame capteur.
class ParallelismData {
final ParallelismStatus status;
/// Inclinaison avant/arrière en degrés (0° = parfaitement vertical).
final double pitchDegrees;
/// Inclinaison gauche/droite en degrés (0° = parfaitement droit).
final double rollDegrees;
const ParallelismData({
required this.status,
required this.pitchDegrees,
required this.rollDegrees,
});
/// Raccourci : vrai si l'appareil est bien aligné.
bool get isAligned => status == ParallelismStatus.aligned;
@override
String toString() =>
'ParallelismData(status: $status, pitch: ${pitchDegrees.toStringAsFixed(1)}°, roll: ${rollDegrees.toStringAsFixed(1)}°)';
}
/// Service de détection du parallélisme par accéléromètre.
///
/// Utilise le package sensors_plus pour lire l'accéléromètre en continu.
/// Calcule le pitch (inclinaison avant/arrière) et le roll (inclinaison latérale).
///
/// Un appareil tenu **à la verticale face à la cible** a :
/// - pitch ≈ 0° (l'axe X est horizontal)
/// - roll ≈ 0° (l'axe Y est vertical, gravité vers le bas)
///
/// En mode **paysage** (appareil à l'horizontale), les axes sont permutés —
/// le service gère les deux orientations automatiquement.
class ParallelismService {
/// Seuil d'angle maximum autorisé en degrés.
/// En dessous → aligned, au-dessus → misaligned.
final double thresholdDegrees;
StreamSubscription<AccelerometerEvent>? _subscription;
final StreamController<ParallelismData> _controller =
StreamController<ParallelismData>.broadcast();
ParallelismService({this.thresholdDegrees = 5.0});
/// Stream de données de parallélisme mis à jour en temps réel.
Stream<ParallelismData> get stream => _controller.stream;
/// Démarre l'écoute de l'accéléromètre.
/// Appeler au moment d'afficher la caméra live.
void start() {
if (_subscription != null) return; // Déjà démarré
_subscription = accelerometerEventStream(
samplingPeriod: SensorInterval.normalInterval, // ~50 ms
).listen(
_onAccelerometerEvent,
onError: (e) {
// En cas d'erreur capteur (simulateur sans IMU par exemple),
// on émet un statut "unknown" pour ne pas bloquer l'UI.
if (!_controller.isClosed) {
_controller.add(
const ParallelismData(
status: ParallelismStatus.unknown,
pitchDegrees: 0,
rollDegrees: 0,
),
);
}
},
);
}
/// Stoppe l'écoute de l'accéléromètre.
/// Appeler quand l'écran caméra est fermé pour économiser la batterie.
void stop() {
_subscription?.cancel();
_subscription = null;
}
/// Libère toutes les ressources.
void dispose() {
stop();
_controller.close();
}
void _onAccelerometerEvent(AccelerometerEvent event) {
if (_controller.isClosed) return;
// L'accéléromètre retourne (x, y, z) en m/s².
// Au repos, la magnitude vaut ~9.81 (gravité terrestre).
//
// Convention capteur Android/iOS (portrait) :
// x → axe horizontal (positif = droite)
// y → axe vertical (positif = haut)
// z → axe profondeur (positif = vers l'utilisateur)
//
// On normalise pour être indépendant de la gravité exacte.
final double gx = event.x;
final double gy = event.y;
final double gz = event.z;
final double magnitude = math.sqrt(gx * gx + gy * gy + gz * gz);
if (magnitude < 1.0) {
// Données aberrantes (quasi-nulles) — on ignore
return;
}
// Normalisation
final double nx = gx / magnitude;
final double ny = gy / magnitude;
final double nz = gz / magnitude;
// Pitch : inclinaison avant/arrière
// En portrait vertical face à l'utilisateur : ny ≈ -1, nz ≈ 0 → pitch ≈ 0°
// On calcule l'angle entre le vecteur gravité projeté et l'axe Y pur.
final double pitchRad = math.asin(nz.clamp(-1.0, 1.0));
final double pitchDeg = pitchRad * (180.0 / math.pi);
// Roll : inclinaison latérale gauche/droite
// En portrait droit : nx ≈ 0 → roll ≈ 0°
final double rollRad = math.atan2(nx, -ny);
final double rollDeg = rollRad * (180.0 / math.pi);
final bool isAligned =
pitchDeg.abs() <= thresholdDegrees &&
rollDeg.abs() <= thresholdDegrees;
_controller.add(
ParallelismData(
status: isAligned
? ParallelismStatus.aligned
: ParallelismStatus.misaligned,
pitchDegrees: pitchDeg,
rollDegrees: rollDeg,
),
);
}
}

View File

@@ -701,6 +701,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.6.0"
sensors_plus:
dependency: "direct main"
description:
name: sensors_plus
sha256: "8e7fa79b4940442bb595bfc0ee9da4af5a22a0fe6ebacc74998245ee9496a82d"
url: "https://pub.dev"
source: hosted
version: "4.0.2"
sensors_plus_platform_interface:
dependency: transitive
description:
name: sensors_plus_platform_interface
sha256: bc472d6cfd622acb4f020e726433ee31788b038056691ba433fec80e448a094f
url: "https://pub.dev"
source: hosted
version: "1.2.0"
shared_preferences:
dependency: "direct main"
description:

View File

@@ -36,7 +36,7 @@ dependencies:
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8
sensors_plus: ^4.0.2
opencv_dart: ^2.1.0
# Image capture from camera/gallery