896 lines
32 KiB
Dart
896 lines
32 KiB
Dart
import 'dart:io';
|
|
import 'dart:math' as math;
|
|
import 'dart:async';
|
|
import 'dart:isolate';
|
|
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';
|
|
|
|
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 '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 (cible circulaire) — on garde le résultat COMPLET
|
|
bool? _alignmentStatus;
|
|
TargetDetectionResult? _targetResult; // NOUVEAU : centre + rayon de la cible
|
|
Timer? _detectionTimer;
|
|
bool _isAnalyzingFrame = false;
|
|
|
|
// NOUVEAU : Données IMU en temps réel
|
|
ParallelismData? _parallelismData;
|
|
StreamSubscription<ParallelismData>? _parallelismSubscription;
|
|
|
|
@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
|
|
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;
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Calcule la couleur et le message depuis l'IMU + la détection de cible
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
/// La cible circulaire est-elle détectée et raisonnablement cadrée ?
|
|
bool get _targetReady {
|
|
final t = _targetResult;
|
|
if (t == null || !t.success) return false;
|
|
final bool centered =
|
|
t.centerX > 0.15 && t.centerX < 0.85 && t.centerY > 0.15 && t.centerY < 0.85;
|
|
final bool bigEnough = t.radius > 0.12;
|
|
return centered && bigEnough;
|
|
}
|
|
|
|
/// Couleur du cadre : dépend UNIQUEMENT du parallélisme IMU.
|
|
/// (La détection de cible sert seulement à dessiner le cercle, 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 la cible est détectée)
|
|
if (_parallelismData!.isAligned) {
|
|
return _targetReady
|
|
? 'PARFAIT — CIBLE DÉTECTÉE, 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;
|
|
_targetResult = null; // reset détection cible
|
|
_parallelismData = null; // NOUVEAU : reset IMU
|
|
});
|
|
|
|
_startAlignmentDetection();
|
|
_startParallelismDetection(); // 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 {
|
|
if (_isAnalyzingFrame) return;
|
|
final now = DateTime.now();
|
|
// Cadence ~1 s : assez réactif pour suivre la cible sans saturer le CPU.
|
|
if (_lastAnalysis != null &&
|
|
now.difference(_lastAnalysis!).inMilliseconds < 1000) 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;
|
|
}
|
|
|
|
// PERF : qualité 50 pour la frame d'analyse temps réel (jetable)
|
|
await File(tempPath).writeAsBytes(img.encodeJpg(converted, quality: 50));
|
|
|
|
final result = await _opencvService.detectTarget(tempPath);
|
|
|
|
try {
|
|
await File(tempPath).delete();
|
|
} catch (_) {}
|
|
|
|
if (mounted && _showLiveCamera) {
|
|
setState(() {
|
|
// On garde le résultat complet pour dessiner le cercle.
|
|
_targetResult = result.success ? result : null;
|
|
|
|
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;
|
|
_targetResult = 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!),
|
|
),
|
|
),
|
|
|
|
// 2. Coins du cadre et mire centrale (sans le cadre rectangulaire)
|
|
Center(
|
|
child: SizedBox(
|
|
width: MediaQuery.of(context).size.width * 0.85,
|
|
height: MediaQuery.of(context).size.width * 0.85,
|
|
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
|
|
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 (MODIFIÉE : ajout dégradation post-capture pour perf)
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
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
|
|
|
|
final XFile photo = await _cameraController!.takePicture();
|
|
|
|
// Pas de redressement perspective automatique ici : il déformait l'image
|
|
// (écrasement en carré + rotation de l'axe de l'ellipse) et gelait le
|
|
// thread UI. Le recentrage ET la rotation se font manuellement à l'écran
|
|
// de centrage suivant, qui est précis et non destructeur.
|
|
String finalPath = photo.path;
|
|
|
|
// PERF : on dégrade fortement la photo juste après la capture.
|
|
// Une résolution réduite suffit pour la calibration et le plotting,
|
|
// et accélère nettement le décodage à chaque écran (chargement plotting).
|
|
try {
|
|
finalPath = await _downscaleForPipeline(finalPath);
|
|
} catch (e) {
|
|
debugPrint('Downscale ignoré: $e');
|
|
}
|
|
|
|
setState(() {
|
|
_selectedImagePath = finalPath;
|
|
_showLiveCamera = false;
|
|
});
|
|
|
|
_analyzeImage();
|
|
} catch (e) {
|
|
debugPrint('Erreur lors du clic photo: $e');
|
|
} finally {
|
|
setState(() => _isLoading = false);
|
|
}
|
|
}
|
|
|
|
/// Réduit fortement la résolution de la photo pour accélérer tous les
|
|
/// écrans suivants (crop, calibration, plotting). 1080 px de côté max
|
|
/// + JPEG qualité 70 : largement suffisant pour la détection visuelle.
|
|
///
|
|
/// Baisser `maxSide` (ex. 900) ou `quality` (ex. 60) rend le chargement
|
|
/// encore plus rapide au prix d'un peu de finesse à l'écran.
|
|
Future<String> _downscaleForPipeline(String sourcePath) async {
|
|
// On résout le chemin de sortie sur le thread UI (path_provider a besoin
|
|
// du canal de plateforme), puis tout le travail lourd (décodage, resize,
|
|
// réencodage JPEG) part dans un Isolate → aucun gel du thread UI.
|
|
final tempDir = await getTemporaryDirectory();
|
|
final outPath =
|
|
'${tempDir.path}/downscaled_${DateTime.now().millisecondsSinceEpoch}.jpg';
|
|
|
|
return Isolate.run(() {
|
|
final bytes = File(sourcePath).readAsBytesSync();
|
|
final decoded = img.decodeImage(bytes);
|
|
if (decoded == null) return sourcePath;
|
|
|
|
const int maxSide = 1080;
|
|
final int longest = math.max(decoded.width, decoded.height);
|
|
if (longest <= maxSide) {
|
|
return sourcePath; // déjà assez petite, rien à faire
|
|
}
|
|
|
|
final double ratio = maxSide / longest;
|
|
final resized = img.copyResize(
|
|
decoded,
|
|
width: (decoded.width * ratio).round(),
|
|
height: (decoded.height * ratio).round(),
|
|
interpolation: img.Interpolation.linear, // rapide
|
|
);
|
|
|
|
File(outPath).writeAsBytesSync(img.encodeJpg(resized, quality: 70));
|
|
return outPath;
|
|
});
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// 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,
|
|
// PERF : limite d'import plus agressive pour accélérer le pipeline
|
|
maxWidth: 1280,
|
|
maxHeight: 1280,
|
|
imageQuality: 75,
|
|
);
|
|
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,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
} |