1004 lines
36 KiB
Dart
1004 lines
36 KiB
Dart
import 'dart:io';
|
||
import 'dart:math' as math;
|
||
import 'dart:async';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter/services.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';
|
||
|
||
import 'package:provider/provider.dart';
|
||
import '../../core/constants/app_constants.dart';
|
||
import '../../core/theme/app_theme.dart';
|
||
import '../../data/models/target_type.dart';
|
||
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';
|
||
import '../../services/parallelism_service.dart'; // NOUVEAU
|
||
import '../../services/object_detection_service.dart'; // NOUVEAU : ML Kit
|
||
import '../../services/target_rectify_service.dart'; // NOUVEAU : redressement
|
||
import 'package:image/image.dart' as img;
|
||
import 'package:path_provider/path_provider.dart';
|
||
|
||
class CaptureScreen extends StatefulWidget {
|
||
const CaptureScreen({super.key});
|
||
|
||
@override
|
||
State<CaptureScreen> createState() => _CaptureScreenState();
|
||
}
|
||
|
||
class _CaptureScreenState extends State<CaptureScreen>
|
||
with SingleTickerProviderStateMixin {
|
||
final ImagePicker _picker = ImagePicker();
|
||
final ImageCropService _cropService = ImageCropService();
|
||
final TargetType _selectedType = TargetType.concentric;
|
||
final OpenCVTargetService _opencvService = OpenCVTargetService();
|
||
|
||
// NOUVEAU : Service IMU de parallélisme
|
||
final ParallelismService _parallelismService = ParallelismService(
|
||
alignThreshold: 25.0, // Seuil pour passer au vert (permissif)
|
||
misalignThreshold: 32.0, // Seuil pour repasser à l'orange
|
||
);
|
||
|
||
String? _selectedImagePath;
|
||
bool _isLoading = false;
|
||
|
||
// Caméra live
|
||
CameraController? _cameraController;
|
||
List<CameraDescription>? _cameras;
|
||
bool _isCameraInitialized = false;
|
||
bool _showLiveCamera = false;
|
||
|
||
// Animation pulsation du viseur
|
||
late AnimationController _scanAnimationController;
|
||
late Animation<double> _scanAnimation;
|
||
|
||
// Détection OpenCV (inchangée)
|
||
bool? _alignmentStatus;
|
||
Timer? _detectionTimer;
|
||
bool _isAnalyzingFrame = false;
|
||
|
||
// NOUVEAU : Données IMU en temps réel
|
||
ParallelismData? _parallelismData;
|
||
StreamSubscription<ParallelismData>? _parallelismSubscription;
|
||
|
||
// NOUVEAU : Détection d'objet ML Kit
|
||
final ObjectDetectionService _objectService = ObjectDetectionService();
|
||
StreamSubscription<List<DetectedObject2D>>? _objectSubscription;
|
||
List<DetectedObject2D> _detectedObjects = [];
|
||
|
||
// NOUVEAU : Service de redressement de cible (warp perspective)
|
||
final TargetRectifyService _rectifyService = TargetRectifyService();
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_scanAnimationController = AnimationController(
|
||
duration: const Duration(seconds: 2),
|
||
vsync: this,
|
||
)..repeat(reverse: true);
|
||
|
||
_scanAnimation = Tween<double>(begin: 0.8, end: 1.0).animate(
|
||
CurvedAnimation(parent: _scanAnimationController, curve: Curves.easeInOut),
|
||
);
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_cameraController?.dispose();
|
||
_scanAnimationController.dispose();
|
||
_detectionTimer?.cancel();
|
||
_parallelismSubscription?.cancel(); // NOUVEAU
|
||
_parallelismService.dispose(); // NOUVEAU
|
||
_objectSubscription?.cancel(); // NOUVEAU
|
||
_objectService.dispose(); // NOUVEAU
|
||
super.dispose();
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────
|
||
// 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 : Démarre l'écoute de la détection d'objet ML Kit
|
||
// ─────────────────────────────────────────────────────────────────────────
|
||
void _startObjectDetection() {
|
||
_objectSubscription?.cancel();
|
||
_objectService.start();
|
||
|
||
_objectSubscription = _objectService.stream.listen((objects) {
|
||
if (mounted && _showLiveCamera) {
|
||
setState(() {
|
||
_detectedObjects = objects;
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
void _stopObjectDetection() {
|
||
_objectSubscription?.cancel();
|
||
_objectSubscription = null;
|
||
_objectService.stop();
|
||
_detectedObjects = [];
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────
|
||
// NOUVEAU : Calcule la couleur et le message depuis les données IMU
|
||
// ─────────────────────────────────────────────────────────────────────────
|
||
|
||
/// Y a-t-il un objet détecté suffisamment grand et globalement centré ?
|
||
DetectedObject2D? get _primaryObject {
|
||
if (_detectedObjects.isEmpty) return null;
|
||
// On prend l'objet le plus grand (le sujet principal le plus probable).
|
||
final sorted = [..._detectedObjects]
|
||
..sort((a, b) => b.area.compareTo(a.area));
|
||
final obj = sorted.first;
|
||
// Filtre : doit occuper une part raisonnable de l'image et être centré.
|
||
final bool bigEnough = obj.area > 0.04; // ~20% × 20%
|
||
final bool centered = obj.centerX > 0.2 &&
|
||
obj.centerX < 0.8 &&
|
||
obj.centerY > 0.2 &&
|
||
obj.centerY < 0.8;
|
||
return (bigEnough && centered) ? obj : null;
|
||
}
|
||
|
||
bool get _objectReady => _primaryObject != null;
|
||
|
||
/// Couleur du cadre : dépend UNIQUEMENT du parallélisme IMU.
|
||
/// (La détection d'objet sert seulement à dessiner une boîte, elle ne
|
||
/// bloque jamais le passage au vert.)
|
||
Color get _frameColor {
|
||
final bool imuAligned =
|
||
_parallelismData == null || _parallelismData!.isAligned;
|
||
return imuAligned ? const Color(0xFF00FF00) : Colors.orange;
|
||
}
|
||
|
||
/// Message d'aide selon le parallélisme IMU.
|
||
String get _alignmentMessage {
|
||
if (_parallelismData == null ||
|
||
_parallelismData!.status == ParallelismStatus.unknown) {
|
||
return 'ALIGNEZ LA CIBLE DANS LE CADRE';
|
||
}
|
||
|
||
// Aligné → message de validation (avec bonus si un objet est cadré)
|
||
if (_parallelismData!.isAligned) {
|
||
return _objectReady
|
||
? 'PARFAIT — OBJET CADRÉ, PRÊT'
|
||
: 'PARALLÈLE OK — PRÊT À PHOTOGRAPHIER';
|
||
}
|
||
|
||
// Mal aligné → 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) {
|
||
if (mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(content: Text('Accès à l\'appareil photo requis')),
|
||
);
|
||
}
|
||
return;
|
||
}
|
||
|
||
setState(() => _isLoading = true);
|
||
try {
|
||
if (_cameraController != null) {
|
||
await _cameraController!.dispose();
|
||
_cameraController = null;
|
||
_isCameraInitialized = false;
|
||
}
|
||
|
||
_cameras = await availableCameras();
|
||
if (_cameras != null && _cameras!.isNotEmpty) {
|
||
_cameraController = CameraController(
|
||
_cameras![0],
|
||
ResolutionPreset.max,
|
||
enableAudio: false,
|
||
imageFormatGroup: ImageFormatGroup.jpeg,
|
||
);
|
||
|
||
await _cameraController!.initialize();
|
||
|
||
setState(() {
|
||
_isCameraInitialized = true;
|
||
_showLiveCamera = true;
|
||
_alignmentStatus = null;
|
||
_parallelismData = null; // NOUVEAU : reset IMU
|
||
});
|
||
|
||
_startAlignmentDetection();
|
||
_startParallelismDetection(); // NOUVEAU
|
||
_startObjectDetection(); // NOUVEAU
|
||
}
|
||
} catch (e) {
|
||
debugPrint('Erreur caméra: $e');
|
||
} finally {
|
||
setState(() => _isLoading = false);
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────
|
||
// Détection OpenCV périodique (inchangée)
|
||
// ─────────────────────────────────────────────────────────────────────────
|
||
void _startAlignmentDetection() {
|
||
_detectionTimer?.cancel();
|
||
_detectionTimer = null;
|
||
|
||
DateTime? _lastAnalysis;
|
||
|
||
_cameraController!.startImageStream((CameraImage cameraImage) async {
|
||
// NOUVEAU : on alimente ML Kit à chaque frame (le service gère son propre
|
||
// throttle interne via _isBusy, donc pas de saturation).
|
||
_objectService.processCameraImage(
|
||
cameraImage,
|
||
_cameras![0],
|
||
DeviceOrientation.portraitUp,
|
||
);
|
||
|
||
if (_isAnalyzingFrame) return;
|
||
final now = DateTime.now();
|
||
if (_lastAnalysis != null &&
|
||
now.difference(_lastAnalysis!).inSeconds < 3) return;
|
||
_lastAnalysis = now;
|
||
_isAnalyzingFrame = true;
|
||
|
||
try {
|
||
final tempDir = await getTemporaryDirectory();
|
||
final tempPath = '${tempDir.path}/frame_analysis.jpg';
|
||
|
||
final img.Image? converted = _convertCameraImage(cameraImage);
|
||
if (converted == null) {
|
||
_isAnalyzingFrame = false;
|
||
return;
|
||
}
|
||
|
||
await File(tempPath).writeAsBytes(img.encodeJpg(converted, quality: 60));
|
||
|
||
final result = await _opencvService.detectTarget(tempPath);
|
||
|
||
try {
|
||
await File(tempPath).delete();
|
||
} catch (_) {}
|
||
|
||
if (mounted && _showLiveCamera) {
|
||
setState(() {
|
||
if (!result.success) {
|
||
_alignmentStatus = null;
|
||
} else {
|
||
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;
|
||
}
|
||
});
|
||
}
|
||
|
||
void _stopAlignmentDetection() {
|
||
_detectionTimer?.cancel();
|
||
_detectionTimer = null;
|
||
try {
|
||
if (_cameraController != null &&
|
||
_cameraController!.value.isStreamingImages) {
|
||
_cameraController!.stopImageStream();
|
||
}
|
||
} catch (_) {}
|
||
_alignmentStatus = null;
|
||
}
|
||
|
||
img.Image? _convertCameraImage(CameraImage cameraImage) {
|
||
try {
|
||
final int width = cameraImage.width;
|
||
final int height = cameraImage.height;
|
||
final int targetW = width ~/ 2;
|
||
final int targetH = height ~/ 2;
|
||
|
||
final yPlane = cameraImage.planes[0];
|
||
final uPlane = cameraImage.planes[1];
|
||
final vPlane = cameraImage.planes[2];
|
||
|
||
final image = img.Image(width: targetW, height: targetH);
|
||
|
||
for (int y = 0; y < targetH; y++) {
|
||
for (int x = 0; x < targetW; x++) {
|
||
final int srcX = x * 2;
|
||
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 yVal = yPlane.bytes[yIndex];
|
||
final int uVal = uPlane.bytes[uvIndex] - 128;
|
||
final int vVal = vPlane.bytes[uvIndex] - 128;
|
||
|
||
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 b = (yVal + 1.772 * uVal).clamp(0, 255).toInt();
|
||
|
||
image.setPixelRgb(x, y, r, g, b);
|
||
}
|
||
}
|
||
|
||
return image;
|
||
} catch (e) {
|
||
debugPrint('Erreur conversion frame: $e');
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────
|
||
// Galerie (inchangée)
|
||
// ─────────────────────────────────────────────────────────────────────────
|
||
Future<void> _handleGallerySelection() async {
|
||
PermissionStatus status;
|
||
|
||
if (Platform.isAndroid) {
|
||
final deviceInfo = DeviceInfoPlugin();
|
||
final androidInfo = await deviceInfo.androidInfo;
|
||
status = androidInfo.version.sdkInt >= 33
|
||
? await Permission.photos.request()
|
||
: await Permission.storage.request();
|
||
} else {
|
||
status = await Permission.photos.request();
|
||
}
|
||
|
||
if (status.isGranted) {
|
||
_captureImageFromGallery();
|
||
} else if (status.isPermanentlyDenied) {
|
||
_showSettingsDialog();
|
||
} else {
|
||
if (mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(
|
||
content: Text('Accès à la galerie requis pour continuer'),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
void _showSettingsDialog() {
|
||
showDialog(
|
||
context: context,
|
||
builder: (context) => AlertDialog(
|
||
title: const Text('Permission requise'),
|
||
content: const Text(
|
||
'L\'accès aux photos est nécessaire. Veuillez l\'activer dans les paramètres.',
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.pop(context),
|
||
child: const Text('Annuler'),
|
||
),
|
||
TextButton(
|
||
onPressed: () => openAppSettings(),
|
||
child: const Text('Paramètres'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────
|
||
// Build principal
|
||
// ─────────────────────────────────────────────────────────────────────────
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final sessionProvider = context.watch<SessionProvider>();
|
||
final title = sessionProvider.isSessionActive
|
||
? 'Cible ${sessionProvider.targetCount + 1}'
|
||
: 'Source';
|
||
|
||
if (_showLiveCamera &&
|
||
_cameraController != null &&
|
||
_cameraController!.value.isInitialized) {
|
||
return _buildLiveCameraView();
|
||
}
|
||
|
||
return Scaffold(
|
||
appBar: AppBar(title: Text(title)),
|
||
body: SingleChildScrollView(
|
||
padding: const EdgeInsets.all(AppConstants.defaultPadding),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
const SizedBox(height: AppConstants.largePadding),
|
||
_buildSectionTitle('Source de l\'Image'),
|
||
const SizedBox(height: 12),
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: ImageSourceButton(
|
||
icon: Icons.camera_alt,
|
||
label: 'Scanner',
|
||
onPressed: _isLoading ? null : _initLiveCamera,
|
||
),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: ImageSourceButton(
|
||
icon: Icons.photo_library,
|
||
label: 'Galerie',
|
||
onPressed: _isLoading ? null : _handleGallerySelection,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: AppConstants.largePadding),
|
||
if (_isLoading)
|
||
const Center(
|
||
child: Padding(
|
||
padding: EdgeInsets.all(32),
|
||
child: CircularProgressIndicator(),
|
||
),
|
||
)
|
||
else
|
||
_buildGuide(),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────
|
||
// Vue caméra live — MODIFIÉE pour utiliser les données IMU
|
||
// ─────────────────────────────────────────────────────────────────────────
|
||
Widget _buildLiveCameraView() {
|
||
// 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. Flux vidéo caméra
|
||
Center(
|
||
child: AspectRatio(
|
||
aspectRatio: 3 / 4,
|
||
child: CameraPreview(_cameraController!),
|
||
),
|
||
),
|
||
|
||
// NOUVEAU : 1.bis Cadre dessiné autour de CHAQUE objet détecté
|
||
if (_detectedObjects.isNotEmpty)
|
||
Center(
|
||
child: AspectRatio(
|
||
aspectRatio: 3 / 4,
|
||
child: CustomPaint(
|
||
painter: _ObjectBoxPainter(
|
||
objects: _detectedObjects,
|
||
primary: _primaryObject,
|
||
color: frameColor,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
|
||
// 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, width: 2.0),
|
||
borderRadius: BorderRadius.circular(16),
|
||
),
|
||
child: Stack(
|
||
children: [
|
||
_buildCameraCorner(TopLeft: true, color: frameColor),
|
||
_buildCameraCorner(TopRight: true, color: frameColor),
|
||
_buildCameraCorner(BottomLeft: true, color: frameColor),
|
||
_buildCameraCorner(BottomRight: true, color: frameColor),
|
||
Center(
|
||
child: Container(
|
||
width: 24,
|
||
height: 24,
|
||
decoration: BoxDecoration(
|
||
shape: BoxShape.circle,
|
||
border: Border.all(color: frameColor, width: 1.2),
|
||
),
|
||
child: Stack(
|
||
children: [
|
||
Center(
|
||
child: Container(
|
||
width: 20,
|
||
height: 1.2,
|
||
color: frameColor,
|
||
),
|
||
),
|
||
Center(
|
||
child: Container(
|
||
width: 1.2,
|
||
height: 20,
|
||
color: frameColor,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
|
||
// 3. Bouton retour
|
||
Positioned(
|
||
top: 40,
|
||
left: 20,
|
||
child: CircleAvatar(
|
||
backgroundColor: Colors.black54,
|
||
child: IconButton(
|
||
icon: const Icon(Icons.arrow_back, color: Colors.white),
|
||
onPressed: () {
|
||
_stopAlignmentDetection();
|
||
_stopParallelismDetection(); // NOUVEAU
|
||
_stopObjectDetection(); // NOUVEAU
|
||
setState(() {
|
||
_showLiveCamera = false;
|
||
});
|
||
},
|
||
),
|
||
),
|
||
),
|
||
|
||
// 4. Message d'alignement principal (texte dynamique IMU)
|
||
Positioned(
|
||
top: 50,
|
||
left: 0,
|
||
right: 0,
|
||
child: Center(
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||
decoration: BoxDecoration(
|
||
color: Colors.black.withValues(alpha: 0.7),
|
||
borderRadius: BorderRadius.circular(20),
|
||
),
|
||
child: Text(
|
||
alignmentMessage,
|
||
style: TextStyle(
|
||
color: frameColor,
|
||
fontWeight: FontWeight.bold,
|
||
fontSize: 12,
|
||
letterSpacing: 1.2,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
|
||
// 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,
|
||
right: 20,
|
||
child: Container(
|
||
width: 10,
|
||
height: 10,
|
||
decoration: const BoxDecoration(
|
||
color: Colors.white54,
|
||
shape: BoxShape.circle,
|
||
),
|
||
),
|
||
),
|
||
|
||
// 5. Bouton déclencheur
|
||
Positioned(
|
||
bottom: 40,
|
||
left: 0,
|
||
right: 0,
|
||
child: Center(
|
||
child: GestureDetector(
|
||
onTap: _takePictureManually,
|
||
child: Container(
|
||
height: 80,
|
||
width: 80,
|
||
decoration: BoxDecoration(
|
||
shape: BoxShape.circle,
|
||
border: Border.all(color: Colors.white, width: 4),
|
||
),
|
||
child: Center(
|
||
child: Container(
|
||
height: 64,
|
||
width: 64,
|
||
decoration: const BoxDecoration(
|
||
color: Colors.white,
|
||
shape: BoxShape.circle,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────
|
||
// 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),
|
||
}) {
|
||
return Positioned(
|
||
top: (TopLeft || TopRight) ? 10 : null,
|
||
bottom: (BottomLeft || BottomRight) ? 10 : null,
|
||
left: (TopLeft || BottomLeft) ? 10 : null,
|
||
right: (TopRight || BottomRight) ? 10 : null,
|
||
child: Container(
|
||
width: 20,
|
||
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,
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────
|
||
// Capture manuelle (inchangée, sauf ajout stop IMU)
|
||
// ─────────────────────────────────────────────────────────────────────────
|
||
Future<void> _takePictureManually() async {
|
||
if (_cameraController == null || !_cameraController!.value.isInitialized) {
|
||
return;
|
||
}
|
||
|
||
setState(() => _isLoading = true);
|
||
try {
|
||
try {
|
||
await _cameraController!.setZoomLevel(1.0);
|
||
} catch (_) {}
|
||
|
||
_stopAlignmentDetection();
|
||
_stopParallelismDetection(); // NOUVEAU
|
||
_stopObjectDetection(); // NOUVEAU
|
||
|
||
final XFile photo = await _cameraController!.takePicture();
|
||
|
||
// NOUVEAU : redressement automatique de la cible (warp perspective).
|
||
// On écrit le résultat dans un fichier dédié, à côté de l'original.
|
||
String finalPath = photo.path;
|
||
try {
|
||
final tempDir = await getTemporaryDirectory();
|
||
final rectifiedPath =
|
||
'${tempDir.path}/rectified_${DateTime.now().millisecondsSinceEpoch}.jpg';
|
||
|
||
final result = await _rectifyService.rectify(
|
||
inputPath: photo.path,
|
||
outputPath: rectifiedPath,
|
||
);
|
||
|
||
finalPath = result.outputPath;
|
||
|
||
if (mounted && result.rectified) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
duration: const Duration(seconds: 2),
|
||
content: Text(
|
||
'Cible redressée automatiquement '
|
||
'(inclinaison ${result.estimatedTiltDegrees.toStringAsFixed(1)}° corrigée)',
|
||
),
|
||
),
|
||
);
|
||
}
|
||
} catch (e) {
|
||
debugPrint('Redressement ignoré: $e');
|
||
finalPath = photo.path; // on garde la photo originale en secours
|
||
}
|
||
|
||
setState(() {
|
||
_selectedImagePath = finalPath;
|
||
_showLiveCamera = false;
|
||
});
|
||
|
||
_analyzeImage();
|
||
} catch (e) {
|
||
debugPrint('Erreur lors du clic photo: $e');
|
||
} finally {
|
||
setState(() => _isLoading = false);
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────
|
||
// Helpers UI (inchangés)
|
||
// ─────────────────────────────────────────────────────────────────────────
|
||
Widget _buildSectionTitle(String title) {
|
||
return Text(
|
||
title,
|
||
style: Theme.of(context)
|
||
.textTheme
|
||
.titleMedium
|
||
?.copyWith(fontWeight: FontWeight.bold),
|
||
);
|
||
}
|
||
|
||
Widget _buildGuide() {
|
||
return Card(
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(AppConstants.defaultPadding),
|
||
child: Column(
|
||
children: [
|
||
Icon(Icons.help_outline, size: 48, color: Colors.grey[400]),
|
||
const SizedBox(height: 12),
|
||
Text(
|
||
'Conseils pour une bonne analyse',
|
||
style: Theme.of(context)
|
||
.textTheme
|
||
.titleSmall
|
||
?.copyWith(fontWeight: FontWeight.bold),
|
||
),
|
||
const SizedBox(height: 12),
|
||
_buildGuideItem(
|
||
Icons.crop_free,
|
||
'Cadrez la cible entière dans l\'image',
|
||
),
|
||
_buildGuideItem(Icons.wb_sunny, 'Utilisez un bon éclairage'),
|
||
_buildGuideItem(Icons.straighten, 'Prenez la photo de face'),
|
||
_buildGuideItem(Icons.blur_off, 'Évitez les images floues'),
|
||
_buildGuideItem(
|
||
Icons.cleaning_services,
|
||
'Nettoyer votre objectif avec une chiffonnette',
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildGuideItem(IconData icon, String text) {
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||
child: Row(
|
||
children: [
|
||
Icon(icon, size: 20, color: AppTheme.primaryColor),
|
||
const SizedBox(width: 12),
|
||
Expanded(child: Text(text)),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Future<void> _captureImageFromGallery() async {
|
||
setState(() => _isLoading = true);
|
||
try {
|
||
final XFile? image = await _picker.pickImage(
|
||
source: ImageSource.gallery,
|
||
maxWidth: 2048,
|
||
maxHeight: 2048,
|
||
imageQuality: 90,
|
||
);
|
||
if (image != null) {
|
||
setState(() {
|
||
_selectedImagePath = image.path;
|
||
});
|
||
_analyzeImage();
|
||
}
|
||
} catch (e) {
|
||
debugPrint('Erreur galerie: $e');
|
||
} finally {
|
||
if (mounted) setState(() => _isLoading = false);
|
||
}
|
||
}
|
||
|
||
void _analyzeImage() {
|
||
if (_selectedImagePath == null) return;
|
||
|
||
Navigator.push(
|
||
context,
|
||
MaterialPageRoute(
|
||
builder: (_) => CropScreen(
|
||
imagePath: _selectedImagePath!,
|
||
targetType: _selectedType,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────
|
||
// Painter qui dessine la boîte englobante de CHAQUE objet détecté.
|
||
// L'objet principal (le plus grand et centré) est mis en évidence avec
|
||
// la couleur d'état (vert/orange) ; les autres en blanc translucide.
|
||
// ─────────────────────────────────────────────────────────────────────────
|
||
class _ObjectBoxPainter extends CustomPainter {
|
||
final List<DetectedObject2D> objects;
|
||
final DetectedObject2D? primary;
|
||
final Color color;
|
||
|
||
_ObjectBoxPainter({
|
||
required this.objects,
|
||
required this.primary,
|
||
required this.color,
|
||
});
|
||
|
||
@override
|
||
void paint(Canvas canvas, Size size) {
|
||
for (final obj in objects) {
|
||
final bool isPrimary = identical(obj, primary);
|
||
final Color boxColor =
|
||
isPrimary ? color : Colors.white.withValues(alpha: 0.6);
|
||
|
||
final Paint stroke = Paint()
|
||
..style = PaintingStyle.stroke
|
||
..strokeWidth = isPrimary ? 3.0 : 1.5
|
||
..color = boxColor;
|
||
|
||
final Rect rect = Rect.fromLTRB(
|
||
obj.left * size.width,
|
||
obj.top * size.height,
|
||
obj.right * size.width,
|
||
obj.bottom * size.height,
|
||
);
|
||
final RRect rrect =
|
||
RRect.fromRectAndRadius(rect, const Radius.circular(8));
|
||
canvas.drawRRect(rrect, stroke);
|
||
|
||
if (obj.label.isNotEmpty) {
|
||
final textPainter = TextPainter(
|
||
text: TextSpan(
|
||
text: ' ${obj.label} '
|
||
'${(obj.confidence * 100).toStringAsFixed(0)}% ',
|
||
style: TextStyle(
|
||
color: Colors.black,
|
||
fontSize: 12,
|
||
fontWeight: FontWeight.bold,
|
||
backgroundColor: boxColor,
|
||
),
|
||
),
|
||
textDirection: TextDirection.ltr,
|
||
)..layout();
|
||
textPainter.paint(
|
||
canvas,
|
||
Offset(rect.left, (rect.top - 18).clamp(0.0, size.height)),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
@override
|
||
bool shouldRepaint(_ObjectBoxPainter old) =>
|
||
old.objects != objects || old.color != color || old.primary != primary;
|
||
}
|