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:io';
import 'dart:math' as math; 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:flutter/material.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
import 'package:device_info_plus/device_info_plus.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 'package:provider/provider.dart';
import '../../core/constants/app_constants.dart'; import '../../core/constants/app_constants.dart';
@@ -15,9 +15,11 @@ import '../crop/crop_screen.dart';
import '../session/session_provider.dart'; import '../session/session_provider.dart';
import 'widgets/image_source_button.dart'; import 'widgets/image_source_button.dart';
import '../../services/image_crop_service.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:image/image.dart' as img;
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
class CaptureScreen extends StatefulWidget { class CaptureScreen extends StatefulWidget {
const CaptureScreen({super.key}); const CaptureScreen({super.key});
@@ -25,34 +27,43 @@ class CaptureScreen extends StatefulWidget {
State<CaptureScreen> createState() => _CaptureScreenState(); State<CaptureScreen> createState() => _CaptureScreenState();
} }
class _CaptureScreenState extends State<CaptureScreen> with SingleTickerProviderStateMixin { class _CaptureScreenState extends State<CaptureScreen>
with SingleTickerProviderStateMixin {
final ImagePicker _picker = ImagePicker(); final ImagePicker _picker = ImagePicker();
final ImageCropService _cropService = ImageCropService(); final ImageCropService _cropService = ImageCropService();
final TargetType _selectedType = TargetType.concentric; 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; String? _selectedImagePath;
bool _isLoading = false; bool _isLoading = false;
// AJOUT : Variables de gestion du flux caméra en direct // Caméra live
CameraController? _cameraController; CameraController? _cameraController;
List<CameraDescription>? _cameras; List<CameraDescription>? _cameras;
bool _isCameraInitialized = false; bool _isCameraInitialized = false;
bool _showLiveCamera = false; bool _showLiveCamera = false;
// AJOUT : Animation pour l'effet "pulsation de détection" du cadre vert // Animation pulsation du viseur
late AnimationController _scanAnimationController; late AnimationController _scanAnimationController;
late Animation<double> _scanAnimation; late Animation<double> _scanAnimation;
// AJOUT : Variables pour le parallélisme // Détection OpenCV (inchangée)
// _alignmentStatus : null = pas encore analysé, true = bien aligné, false = mal aligné
bool? _alignmentStatus; bool? _alignmentStatus;
Timer? _detectionTimer; 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 @override
void initState() { void initState() {
super.initState(); super.initState();
// Initialisation de l'animation du viseur vert (effet pulsation)
_scanAnimationController = AnimationController( _scanAnimationController = AnimationController(
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
vsync: this, vsync: this,
@@ -67,11 +78,72 @@ class _CaptureScreenState extends State<CaptureScreen> with SingleTickerProvider
void dispose() { void dispose() {
_cameraController?.dispose(); _cameraController?.dispose();
_scanAnimationController.dispose(); _scanAnimationController.dispose();
_detectionTimer?.cancel(); // AJOUT : On stoppe le timer proprement _detectionTimer?.cancel();
_parallelismSubscription?.cancel(); // NOUVEAU
_parallelismService.dispose(); // NOUVEAU
super.dispose(); 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 { Future<void> _initLiveCamera() async {
final status = await Permission.camera.request(); final status = await Permission.camera.request();
if (!status.isGranted) { if (!status.isGranted) {
@@ -85,8 +157,6 @@ class _CaptureScreenState extends State<CaptureScreen> with SingleTickerProvider
setState(() => _isLoading = true); setState(() => _isLoading = true);
try { 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) { if (_cameraController != null) {
await _cameraController!.dispose(); await _cameraController!.dispose();
_cameraController = null; _cameraController = null;
@@ -97,22 +167,22 @@ class _CaptureScreenState extends State<CaptureScreen> with SingleTickerProvider
if (_cameras != null && _cameras!.isNotEmpty) { if (_cameras != null && _cameras!.isNotEmpty) {
_cameraController = CameraController( _cameraController = CameraController(
_cameras![0], _cameras![0],
ResolutionPreset.max, // Force le 4:3 natif au maximum du capteur ResolutionPreset.max,
enableAudio: false, enableAudio: false,
imageFormatGroup: ImageFormatGroup.jpeg, imageFormatGroup: ImageFormatGroup.jpeg,
); );
// INITIALISATION UNIQUE ET PROPRE :
await _cameraController!.initialize(); await _cameraController!.initialize();
setState(() { setState(() {
_isCameraInitialized = true; _isCameraInitialized = true;
_showLiveCamera = 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(); _startAlignmentDetection();
_startParallelismDetection(); // NOUVEAU
} }
} catch (e) { } catch (e) {
debugPrint('Erreur caméra: $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() { void _startAlignmentDetection() {
_detectionTimer?.cancel(); _detectionTimer?.cancel();
_detectionTimer = null; _detectionTimer = null;
// OPTIMISATION : startImageStream au lieu de takePicture
// On analyse 1 frame toutes les 3 secondes via le stream natif
DateTime? _lastAnalysis; DateTime? _lastAnalysis;
_cameraController!.startImageStream((CameraImage cameraImage) async { _cameraController!.startImageStream((CameraImage cameraImage) async {
@@ -138,24 +209,22 @@ class _CaptureScreenState extends State<CaptureScreen> with SingleTickerProvider
_isAnalyzingFrame = true; _isAnalyzingFrame = true;
try { try {
// Convertir la CameraImage en fichier temporaire pour OpenCV
final tempDir = await getTemporaryDirectory(); final tempDir = await getTemporaryDirectory();
final tempPath = final tempPath = '${tempDir.path}/frame_analysis.jpg';
'${tempDir.path}/frame_analysis.jpg';
// Conversion YUV420 → JPEG via le package image
final img.Image? converted = _convertCameraImage(cameraImage); final img.Image? converted = _convertCameraImage(cameraImage);
if (converted == null) { if (converted == null) {
_isAnalyzingFrame = false; _isAnalyzingFrame = false;
return; return;
} }
await File(tempPath) await File(tempPath).writeAsBytes(img.encodeJpg(converted, quality: 60));
.writeAsBytes(img.encodeJpg(converted, quality: 60));
final result = await _opencvService.detectTarget(tempPath); final result = await _opencvService.detectTarget(tempPath);
try { await File(tempPath).delete(); } catch (_) {} try {
await File(tempPath).delete();
} catch (_) {}
if (mounted && _showLiveCamera) { if (mounted && _showLiveCamera) {
setState(() { setState(() {
@@ -178,11 +247,9 @@ class _CaptureScreenState extends State<CaptureScreen> with SingleTickerProvider
}); });
} }
// AJOUT : Stoppe le timer de détection
void _stopAlignmentDetection() { void _stopAlignmentDetection() {
_detectionTimer?.cancel(); _detectionTimer?.cancel();
_detectionTimer = null; _detectionTimer = null;
// AJOUT : Stopper le stream proprement
try { try {
if (_cameraController != null && if (_cameraController != null &&
_cameraController!.value.isStreamingImages) { _cameraController!.value.isStreamingImages) {
@@ -192,14 +259,10 @@ class _CaptureScreenState extends State<CaptureScreen> with SingleTickerProvider
_alignmentStatus = null; _alignmentStatus = null;
} }
// AJOUT : Conversion CameraImage (YUV420) vers img.Image
img.Image? _convertCameraImage(CameraImage cameraImage) { img.Image? _convertCameraImage(CameraImage cameraImage) {
try { try {
final int width = cameraImage.width; final int width = cameraImage.width;
final int height = cameraImage.height; 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 targetW = width ~/ 2;
final int targetH = height ~/ 2; final int targetH = height ~/ 2;
@@ -215,16 +278,17 @@ class _CaptureScreenState extends State<CaptureScreen> with SingleTickerProvider
final int srcY = y * 2; final int srcY = y * 2;
final int yIndex = srcY * yPlane.bytesPerRow + srcX; final int yIndex = srcY * yPlane.bytesPerRow + srcX;
final int uvIndex = final int uvIndex = (srcY ~/ 2) * uPlane.bytesPerRow +
(srcY ~/ 2) * uPlane.bytesPerRow + (srcX ~/ 2) * uPlane.bytesPerPixel!; (srcX ~/ 2) * uPlane.bytesPerPixel!;
final int yVal = yPlane.bytes[yIndex]; final int yVal = yPlane.bytes[yIndex];
final int uVal = uPlane.bytes[uvIndex] - 128; final int uVal = uPlane.bytes[uvIndex] - 128;
final int vVal = vPlane.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 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(); final int b = (yVal + 1.772 * uVal).clamp(0, 255).toInt();
image.setPixelRgb(x, y, r, g, b); 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 {/* // Galerie (inchangée)
// 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<void> _handleGallerySelection() async { Future<void> _handleGallerySelection() async {
PermissionStatus status; PermissionStatus status;
if (Platform.isAndroid) { if (Platform.isAndroid) {
final deviceInfo = DeviceInfoPlugin(); final deviceInfo = DeviceInfoPlugin();
final androidInfo = await deviceInfo.androidInfo; final androidInfo = await deviceInfo.androidInfo;
status = androidInfo.version.sdkInt >= 33
if (androidInfo.version.sdkInt >= 33) { ? await Permission.photos.request()
status = await Permission.photos.request(); : await Permission.storage.request();
} else {
status = await Permission.storage.request();
}
} else { } else {
status = await Permission.photos.request(); status = await Permission.photos.request();
} }
@@ -336,6 +355,9 @@ class _CaptureScreenState extends State<CaptureScreen> with SingleTickerProvider
); );
} }
// ─────────────────────────────────────────────────────────────────────────
// Build principal
// ─────────────────────────────────────────────────────────────────────────
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final sessionProvider = context.watch<SessionProvider>(); final sessionProvider = context.watch<SessionProvider>();
@@ -343,8 +365,9 @@ class _CaptureScreenState extends State<CaptureScreen> with SingleTickerProvider
? 'Cible ${sessionProvider.targetCount + 1}' ? 'Cible ${sessionProvider.targetCount + 1}'
: 'Source'; : 'Source';
// AJOUT : Si l'utilisateur clique sur scanner, on affiche la caméra plein écran avec l'overlay vert if (_showLiveCamera &&
if (_showLiveCamera && _cameraController != null && _cameraController!.value.isInitialized) { _cameraController != null &&
_cameraController!.value.isInitialized) {
return _buildLiveCameraView(); return _buildLiveCameraView();
} }
@@ -356,7 +379,6 @@ class _CaptureScreenState extends State<CaptureScreen> with SingleTickerProvider
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
const SizedBox(height: AppConstants.largePadding), const SizedBox(height: AppConstants.largePadding),
_buildSectionTitle('Source de l\'Image'), _buildSectionTitle('Source de l\'Image'),
const SizedBox(height: 12), const SizedBox(height: 12),
Row( Row(
@@ -365,7 +387,7 @@ class _CaptureScreenState extends State<CaptureScreen> with SingleTickerProvider
child: ImageSourceButton( child: ImageSourceButton(
icon: Icons.camera_alt, icon: Icons.camera_alt,
label: 'Scanner', label: 'Scanner',
onPressed: _isLoading ? null : _initLiveCamera, // Ouvre notre caméra intégrée onPressed: _isLoading ? null : _initLiveCamera,
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
@@ -379,7 +401,6 @@ class _CaptureScreenState extends State<CaptureScreen> with SingleTickerProvider
], ],
), ),
const SizedBox(height: AppConstants.largePadding), const SizedBox(height: AppConstants.largePadding),
if (_isLoading) if (_isLoading)
const Center( const Center(
child: Padding( 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 // Vue caméra live — MODIFIÉE pour utiliser les données IMU
// INTERFACE : Restauration de TON ancienne cible centrale verte d'origine // ─────────────────────────────────────────────────────────────────────────
Widget _buildLiveCameraView() { Widget _buildLiveCameraView() {
// AJOUT : Couleur du cadre selon le statut d'alignement // Priorité : IMU (parallélisme physique) prime sur OpenCV (centrage)
// null = vert (pas encore analysé), true = vert (bien aligné), false = orange (mal aligné) // Si l'IMU dit "mal aligné" → orange, sinon → vert
final Color frameColor = _alignmentStatus == false final Color frameColor = _frameColor;
? Colors.orange final String alignmentMessage = _alignmentMessage;
: 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( return Scaffold(
backgroundColor: Colors.black, backgroundColor: Colors.black,
body: Stack( body: Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
// 1. Le flux vidéo de la caméra // 1. Flux vidéo caméra
Center( Center(
child: AspectRatio( child: AspectRatio(
aspectRatio: 3 / 4, // Aligné sur le format natif du capteur photo aspectRatio: 3 / 4,
child: CameraPreview(_cameraController!), 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( Center(
child: Container( child: Container(
width: MediaQuery.of(context).size.width * 0.85, width: MediaQuery.of(context).size.width * 0.85,
height: MediaQuery.of(context).size.width * 0.85, height: MediaQuery.of(context).size.width * 0.85,
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border.all( border: Border.all(color: frameColor, width: 2.0),
color: frameColor, // MODIFIÉ : Couleur dynamique selon l'alignement
width: 2.0,
),
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
), ),
child: Stack( child: Stack(
children: [ children: [
// SÉCURITÉ : On réintègre les 4 coins renforcés sur les côtés !
_buildCameraCorner(TopLeft: true, color: frameColor), _buildCameraCorner(TopLeft: true, color: frameColor),
_buildCameraCorner(TopRight: true, color: frameColor), _buildCameraCorner(TopRight: true, color: frameColor),
_buildCameraCorner(BottomLeft: true, color: frameColor), _buildCameraCorner(BottomLeft: true, color: frameColor),
_buildCameraCorner(BottomRight: true, color: frameColor), _buildCameraCorner(BottomRight: true, color: frameColor),
// TA MIRE CENTRALE HISTORIQUE FINALE
Center( Center(
child: Container( child: Container(
width: 24, // Diamètre global de la petite cible width: 24,
height: 24, height: 24,
decoration: BoxDecoration( decoration: BoxDecoration(
shape: BoxShape.circle, shape: BoxShape.circle,
border: Border.all( border: Border.all(color: frameColor, width: 1.2),
color: frameColor, // MODIFIÉ : Couleur dynamique
width: 1.2, // Ligne fine
),
), ),
child: Stack( child: Stack(
children: [ children: [
// Trait horizontal fin confiné
Center( Center(
child: Container( child: Container(
width: 20, width: 20,
height: 1.2, height: 1.2,
color: frameColor, // MODIFIÉ : Couleur dynamique color: frameColor,
), ),
), ),
// Trait vertical fin confiné
Center( Center(
child: Container( child: Container(
width: 1.2, width: 1.2,
height: 20, 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( Positioned(
top: 40, top: 40,
left: 20, left: 20,
@@ -493,8 +495,8 @@ class _CaptureScreenState extends State<CaptureScreen> with SingleTickerProvider
child: IconButton( child: IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white), icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: () { onPressed: () {
// AJOUT : On stoppe la détection quand on quitte la caméra
_stopAlignmentDetection(); _stopAlignmentDetection();
_stopParallelismDetection(); // NOUVEAU
setState(() { setState(() {
_showLiveCamera = false; _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( Positioned(
top: 50, top: 50,
left: 0, left: 0,
@@ -518,7 +520,7 @@ class _CaptureScreenState extends State<CaptureScreen> with SingleTickerProvider
child: Text( child: Text(
alignmentMessage, alignmentMessage,
style: TextStyle( style: TextStyle(
color: frameColor, // MODIFIÉ : Couleur dynamique color: frameColor,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: 12, fontSize: 12,
letterSpacing: 1.2, 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) if (_isAnalyzingFrame)
Positioned( Positioned(
top: 45, 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( Positioned(
bottom: 40, bottom: 40,
left: 0, 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({ Widget _buildCameraCorner({
bool TopLeft = false, bool TopLeft = false,
bool TopRight = false, bool TopRight = false,
bool BottomLeft = false, bool BottomLeft = false,
bool BottomRight = false, bool BottomRight = false,
Color color = const Color(0xFF00FF00), // AJOUT : Couleur dynamique Color color = const Color(0xFF00FF00),
}) { }) {
return Positioned( return Positioned(
top: (TopLeft || TopRight) ? 10 : null, top: (TopLeft || TopRight) ? 10 : null,
@@ -595,34 +671,41 @@ class _CaptureScreenState extends State<CaptureScreen> with SingleTickerProvider
height: 20, height: 20,
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border( border: Border(
top: (TopLeft || TopRight) ? BorderSide(color: color, width: 4) : BorderSide.none, top: (TopLeft || TopRight)
bottom: (BottomLeft || BottomRight) ? BorderSide(color: color, width: 4) : BorderSide.none, ? BorderSide(color: color, width: 4)
left: (TopLeft || BottomLeft) ? BorderSide(color: color, width: 4) : BorderSide.none, : BorderSide.none,
right: (TopRight || BottomRight) ? 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 { Future<void> _takePictureManually() async {
if (_cameraController == null || !_cameraController!.value.isInitialized) return; if (_cameraController == null || !_cameraController!.value.isInitialized) {
return;
}
setState(() => _isLoading = true); setState(() => _isLoading = true);
try { try {
// CORRECTIF ZOOM : On remet le zoom à 1.0 avant la capture pour ne pas
// propager le zoom optique dans les écrans suivants
try { try {
await _cameraController!.setZoomLevel(1.0); await _cameraController!.setZoomLevel(1.0);
} catch (_) { } 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(); _stopAlignmentDetection();
_stopParallelismDetection(); // NOUVEAU
// 1. On prend la photo brute normale (sans y toucher)
final XFile photo = await _cameraController!.takePicture(); final XFile photo = await _cameraController!.takePicture();
setState(() { setState(() {
@@ -630,7 +713,6 @@ class _CaptureScreenState extends State<CaptureScreen> with SingleTickerProvider
_showLiveCamera = false; _showLiveCamera = false;
}); });
// 2. On l'envoie directement à l'analyse
_analyzeImage(); _analyzeImage();
} catch (e) { } catch (e) {
debugPrint('Erreur lors du clic photo: $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) { Widget _buildSectionTitle(String title) {
return Text( return Text(
title, title,
style: Theme.of( style: Theme.of(context)
context, .textTheme
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), .titleMedium
?.copyWith(fontWeight: FontWeight.bold),
); );
} }
@@ -658,9 +744,10 @@ class _CaptureScreenState extends State<CaptureScreen> with SingleTickerProvider
const SizedBox(height: 12), const SizedBox(height: 12),
Text( Text(
'Conseils pour une bonne analyse', 'Conseils pour une bonne analyse',
style: Theme.of( style: Theme.of(context)
context, .textTheme
).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold), .titleSmall
?.copyWith(fontWeight: FontWeight.bold),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
_buildGuideItem( _buildGuideItem(
@@ -693,7 +780,6 @@ class _CaptureScreenState extends State<CaptureScreen> with SingleTickerProvider
); );
} }
// Capture depuis la galerie d'images
Future<void> _captureImageFromGallery() async { Future<void> _captureImageFromGallery() async {
setState(() => _isLoading = true); setState(() => _isLoading = true);
try { 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" url: "https://pub.dev"
source: hosted source: hosted
version: "0.6.0" 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: shared_preferences:
dependency: "direct main" dependency: "direct main"
description: description:

View File

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