feat: construction du service parallelisme
This commit is contained in:
@@ -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 {
|
||||
|
||||
156
lib/services/parallelism_service.dart
Normal file
156
lib/services/parallelism_service.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
16
pubspec.lock
16
pubspec.lock
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user