5 Commits
V.0.0.5 ... dev

Author SHA1 Message Date
qguillaume
1e27d01f17 fix: on enleve les 85% qui rognent puisqu on utilise mlkit et non le cadre vert 2026-06-07 01:06:11 +02:00
qguillaume
8dd78f6b80 feat: test effacer partie overlay 2026-06-06 11:22:53 +02:00
qguillaume
23c3bb178f feat: reintegration (en cours) de ml kit lecteur de doc 2026-06-06 01:32:16 +02:00
qguillaume
18e591f3fc fix: legere baisse resolution pour augmenter fluidité de l appli 2026-06-06 00:26:38 +02:00
qguillaume
13cf5b70e0 fix: retour arriere leger zoom du au 85% crop 2026-06-05 23:55:43 +02:00
6 changed files with 261 additions and 834 deletions

View File

@@ -37,6 +37,9 @@
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<meta-data
android:name="com.google.mlkit.vision.DEPENDENCIES"
android:value="docscanner" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and

View File

@@ -28,6 +28,11 @@ import 'widgets/grouping_stats.dart';
class AnalysisScreen extends StatelessWidget {
final String imagePath;
/// Image originale (jamais recadrée), transmise pour pouvoir revenir au
/// centrage sans recadrer un résultat déjà recadré.
final String? originalImagePath;
final TargetType targetType;
final double? initialCenterX;
final double? initialCenterY;
@@ -38,6 +43,7 @@ class AnalysisScreen extends StatelessWidget {
const AnalysisScreen({
super.key,
required this.imagePath,
this.originalImagePath,
required this.targetType,
this.initialCenterX,
this.initialCenterY,
@@ -75,6 +81,7 @@ class AnalysisScreen extends StatelessWidget {
return p;
},
child: _AnalysisScreenContent(
originalImagePath: originalImagePath ?? imagePath,
cropScale: cropScale,
cropOffset: cropOffset,
cropRotation: cropRotation, // Envoyé à la structure d'affichage
@@ -84,11 +91,13 @@ class AnalysisScreen extends StatelessWidget {
}
class _AnalysisScreenContent extends StatefulWidget {
final String originalImagePath;
final double? cropScale;
final Offset? cropOffset;
final double? cropRotation;
const _AnalysisScreenContent({
required this.originalImagePath,
this.cropScale,
this.cropOffset,
this.cropRotation,
@@ -151,6 +160,22 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
}
}
/// Revient au Centrage en repartant de l'image ORIGINALE.
/// On ne restaure que la rotation (jamais le zoom) → pas de cumul.
void _goBackToCrop(AnalysisProvider provider) {
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (context) => CropScreen(
imagePath: widget.originalImagePath,
originalImagePath: widget.originalImagePath,
targetType: provider.targetType!,
initialRotation: provider.cropRotation,
),
),
);
}
@override
Widget build(BuildContext context) {
final provider = context.watch<AnalysisProvider>();
@@ -172,17 +197,7 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
onPressed: () {
if (_isCalibrating) {
final provider = context.read<AnalysisProvider>();
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (context) => CropScreen(
imagePath: provider.imagePath!,
targetType: provider.targetType!,
initialScale: widget.cropScale,
initialOffset: widget.cropOffset,
),
),
);
_goBackToCrop(provider);
} else {
setState(() => _isCalibrating = true);
}
@@ -698,17 +713,18 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
TextButton(
onPressed: () {
Navigator.pop(context);
final path = provider.imagePath!;
final type = provider.targetType!;
// Retour au centrage : on repart de l'ORIGINAL (pas de re-crop),
// en conservant uniquement la rotation.
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (context) => CropScreen(
imagePath: path,
imagePath: widget.originalImagePath,
originalImagePath: widget.originalImagePath,
targetType: type,
initialScale: widget.cropScale,
initialOffset: widget.cropOffset,
initialRotation: provider.cropRotation,
),
),
);

View File

@@ -1,11 +1,8 @@
import 'dart:io';
import 'dart:math' as math;
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';
import 'package:provider/provider.dart';
import '../../core/constants/app_constants.dart';
@@ -15,11 +12,7 @@ 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/target_rectify_service.dart'; // NOUVEAU : redressement
import 'package:image/image.dart' as img;
import 'package:path_provider/path_provider.dart';
import '../../services/document_scanner_service.dart'; // Scanner ML Kit : redresse la cible de face
class CaptureScreen extends StatefulWidget {
const CaptureScreen({super.key});
@@ -28,307 +21,103 @@ class CaptureScreen extends StatefulWidget {
State<CaptureScreen> createState() => _CaptureScreenState();
}
class _CaptureScreenState extends State<CaptureScreen>
with SingleTickerProviderStateMixin {
class _CaptureScreenState extends State<CaptureScreen> {
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
);
// Scanner de documents Google ML Kit (redresse la cible de face automatiquement)
final DocumentScannerService _docScanner = DocumentScannerService();
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;
// Service de redressement de cible (warp perspective)
final TargetRectifyService _rectifyService = TargetRectifyService();
// ─────────────────────────────────────────────────────────────────────────
// Build principal
// ─────────────────────────────────────────────────────────────────────────
@override
void initState() {
super.initState();
_scanAnimationController = AnimationController(
duration: const Duration(seconds: 2),
vsync: this,
)..repeat(reverse: true);
Widget build(BuildContext context) {
final sessionProvider = context.watch<SessionProvider>();
final title = sessionProvider.isSessionActive
? 'Cible ${sessionProvider.targetCount + 1}'
: 'Source';
_scanAnimation = Tween<double>(begin: 0.8, end: 1.0).animate(
CurvedAnimation(parent: _scanAnimationController, curve: Curves.easeInOut),
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.document_scanner,
label: 'Scanner',
onPressed: _isLoading ? null : _scanWithDocumentScanner,
),
),
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(),
],
),
),
);
}
@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
// Déclic → lance le scanner de documents Google ML Kit qui redresse
// automatiquement la cible (feuille rectangulaire) de face.
// ─────────────────────────────────────────────────────────────────────────
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;
}
Future<void> _scanWithDocumentScanner() async {
setState(() => _isLoading = true);
try {
if (_cameraController != null) {
await _cameraController!.dispose();
_cameraController = null;
_isCameraInitialized = false;
}
final result = await _docScanner.scanTarget();
_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;
if (!result.success) {
// L'utilisateur a annulé le scan : on ne fait rien de plus.
if (mounted) setState(() => _isLoading = 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(() {
// 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;
}
_selectedImagePath = result.imagePath;
});
}
_analyzeImage();
} catch (e) {
debugPrint('Erreur analyse frame: $e');
debugPrint('Erreur scanner document: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Le scan a échoué. Réessayez.'),
),
);
}
} 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;
if (mounted) setState(() => _isLoading = false);
}
}
@@ -385,420 +174,44 @@ class _CaptureScreenState extends State<CaptureScreen>
);
}
// ─────────────────────────────────────────────────────────────────────────
// 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!),
),
),
// 1.bis Cercle dessiné autour de la cible circulaire détectée
if (_targetResult != null && _targetResult!.success)
Center(
child: AspectRatio(
aspectRatio: 3 / 4,
child: CustomPaint(
painter: _TargetCirclePainter(
target: _targetResult!,
color: _targetReady ? frameColor : Colors.white,
highlighted: _targetReady,
),
),
),
),
// 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
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;
}
Future<void> _captureImageFromGallery() async {
setState(() => _isLoading = true);
try {
try {
await _cameraController!.setZoomLevel(1.0);
} catch (_) {}
_stopAlignmentDetection();
_stopParallelismDetection(); // 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,
final XFile? image = await _picker.pickImage(
source: ImageSource.gallery,
maxWidth: 2048,
maxHeight: 2048,
imageQuality: 90,
);
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
}
if (image != null) {
setState(() {
_selectedImagePath = finalPath;
_showLiveCamera = false;
_selectedImagePath = image.path;
});
_analyzeImage();
} catch (e) {
debugPrint('Erreur lors du clic photo: $e');
} finally {
setState(() => _isLoading = false);
}
} 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,
),
),
);
}
// ─────────────────────────────────────────────────────────────────────────
// Helpers UI (inchangés)
// Helpers UI
// ─────────────────────────────────────────────────────────────────────────
Widget _buildSectionTitle(String title) {
return Text(
@@ -855,105 +268,4 @@ class _CaptureScreenState extends State<CaptureScreen>
),
);
}
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 un cercle autour de la cible circulaire détectée.
// Le centre et le rayon viennent d'OpenCV (coordonnées normalisées 0..1).
// Blanc tant que la cible n'est pas bien cadrée, couleur d'état sinon.
// ─────────────────────────────────────────────────────────────────────────
class _TargetCirclePainter extends CustomPainter {
final TargetDetectionResult target;
final Color color;
final bool highlighted;
_TargetCirclePainter({
required this.target,
required this.color,
required this.highlighted,
});
@override
void paint(Canvas canvas, Size size) {
// Centre en pixels dans la zone de dessin
final double cx = target.centerX * size.width;
final double cy = target.centerY * size.height;
// radius est normalisé par min(largeur, hauteur) côté OpenCV
final double r = target.radius * math.min(size.width, size.height);
final Paint stroke = Paint()
..style = PaintingStyle.stroke
..strokeWidth = highlighted ? 3.5 : 2.0
..color = color;
// Cercle principal autour de la cible
canvas.drawCircle(Offset(cx, cy), r, stroke);
// Petite croix au centre détecté
final Paint cross = Paint()
..strokeWidth = 2.0
..color = color;
const double k = 10;
canvas.drawLine(Offset(cx - k, cy), Offset(cx + k, cy), cross);
canvas.drawLine(Offset(cx, cy - k), Offset(cx, cy + k), cross);
// Étiquette "CIBLE" quand elle est bien cadrée
if (highlighted) {
final tp = TextPainter(
text: TextSpan(
text: ' CIBLE ',
style: TextStyle(
color: Colors.black,
fontSize: 12,
fontWeight: FontWeight.bold,
backgroundColor: color,
),
),
textDirection: TextDirection.ltr,
)..layout();
tp.paint(canvas, Offset(cx - tp.width / 2, (cy - r - 20).clamp(0.0, size.height)));
}
}
@override
bool shouldRepaint(_TargetCirclePainter old) =>
old.target != target ||
old.color != color ||
old.highlighted != highlighted;
}

View File

@@ -9,17 +9,28 @@ import '../analysis/analysis_screen.dart';
import 'widgets/crop_overlay.dart';
class CropScreen extends StatefulWidget {
/// Image affichée/recadrée dans cet écran. Au tout premier passage, c'est
/// aussi l'image originale. Lors des allers-retours, on recharge toujours
/// l'ORIGINAL ici (jamais une image déjà recadrée) pour éviter le zoom
/// cumulatif.
final String imagePath;
/// Chemin de l'image ORIGINALE (jamais recadrée). Si null, [imagePath] est
/// considéré comme l'original (premier passage depuis la caméra/galerie).
final String? originalImagePath;
final TargetType targetType;
final double? initialScale;
final Offset? initialOffset;
/// Rotation à restaurer au retour (en degrés). Le zoom n'est volontairement
/// PAS restauré : on repart toujours de l'image entière.
final double? initialRotation;
const CropScreen({
super.key,
required this.imagePath,
this.originalImagePath,
required this.targetType,
this.initialScale,
this.initialOffset,
this.initialRotation,
});
@override
@@ -45,14 +56,20 @@ class _CropScreenState extends State<CropScreen> {
late Size _viewportSize;
late double _cropSize;
/// L'image effectivement travaillée par cet écran : toujours l'originale.
String get _workingImagePath =>
widget.originalImagePath ?? widget.imagePath;
@override
void initState() {
super.initState();
// On restaure uniquement la rotation (pas le zoom).
_rotation = widget.initialRotation ?? 0.0;
_loadImageInfo();
}
Future<void> _loadImageInfo() async {
final file = File(widget.imagePath);
final file = File(_workingImagePath);
final decodedImage = await decodeImageFromList(await file.readAsBytes());
if (mounted) {
setState(() {
@@ -251,10 +268,11 @@ class _CropScreenState extends State<CropScreen> {
displayWidth = _viewportSize.height * imageAspect;
}
// On passe de 0.95 à 0.85 pour matcher parfaitement avec l'appareil photo !
_cropSize = math.min(displayWidth, displayHeight)* 0.85;
// Le cadre couvre TOUT le petit côté de l'image affichée (carré maximum),
// au lieu des 85% précédents qui rognaient inutilement les bords.
_cropSize = math.min(displayWidth, displayHeight);
if (_scale == 1.0 && _offset == Offset.zero && _rotation == 0.0) {
if (_scale == 1.0 && _offset == Offset.zero) {
_initializeImagePosition();
}
@@ -273,7 +291,7 @@ class _CropScreenState extends State<CropScreen> {
..rotateZ(_rotation * (math.pi / 180)),
alignment: Alignment.center,
child: Image.file(
File(widget.imagePath),
File(_workingImagePath),
fit: BoxFit.contain,
width: _viewportSize.width,
height: _viewportSize.height,
@@ -358,18 +376,10 @@ class _CropScreenState extends State<CropScreen> {
displayWidth = _viewportSize.height * imageAspect;
}
final minDisplayDim = math.min(displayWidth, displayHeight);
// 2. Calcul du scale initial basé sur la dimension de l'image (et non du viewport)
_scale = widget.initialScale ?? 1.0;
if (_scale < 1.0 && widget.initialScale == null) _scale = 1.0;
// 3. Réinitialisation propre de l'offset au centre de la zone d'affichage
if (widget.initialOffset != null) {
_offset = widget.initialOffset!;
} else {
_offset = Offset.zero; // Force l'image à se centrer parfaitement sur la croix verte
}
// On repart TOUJOURS de l'image entière, centrée, sans zoom :
// c'est ce qui empêche tout cumul de recadrage entre les allers-retours.
_scale = 1.0;
_offset = Offset.zero;
}
void _onScaleStart(ScaleStartDetails details) {
@@ -402,9 +412,10 @@ class _CropScreenState extends State<CropScreen> {
_scale = savedScale;
_offset = savedOffset;
// AJOUT DE LA DECOUPE ET DU REDRESSEMENT GÉOMÉTRIQUE PHYSIQUE
// Le crop est TOUJOURS calculé sur l'image originale, jamais sur un
// résultat déjà recadré.
final croppedImagePath = await _cropService.cropToSquare(
widget.imagePath,
_workingImagePath,
cropRect,
rotationDegrees: _rotation,
);
@@ -419,12 +430,13 @@ class _CropScreenState extends State<CropScreen> {
MaterialPageRoute(
builder: (_) => AnalysisScreen(
imagePath: croppedImagePath,
// On fait suivre l'ORIGINAL et la rotation choisie, pour pouvoir
// revenir au centrage sans jamais re-recadrer le résultat.
originalImagePath: _workingImagePath,
cropRotation: _rotation,
targetType: widget.targetType,
initialCenterX: targetCenterX,
initialCenterY: targetCenterY,
cropScale: 1.0,
cropOffset: Offset.zero,
cropRotation: 0.0,
),
),
);

View File

@@ -0,0 +1,54 @@
import 'package:google_mlkit_document_scanner/google_mlkit_document_scanner.dart';
/// Résultat du scan de document.
class DocumentScanResult {
/// Chemin de l'image redressée (de face), ou null si l'utilisateur a annulé.
final String? imagePath;
/// true si un scan a bien été produit.
bool get success => imagePath != null;
const DocumentScanResult(this.imagePath);
}
/// Service qui lance le scanner de documents Google ML Kit.
///
/// Le scanner ouvre une UI plein écran fournie par Google Play Services :
/// caméra, détection des bords de la feuille en direct, recadrage manuel des
/// 4 coins, puis renvoie l'image REDRESSÉE de face. Idéal pour une cible de
/// tir, qui est imprimée sur un carton/feuille rectangulaire.
///
/// NOTE : disponible uniquement sur Android (fonctionnalité ML Kit en bêta).
class DocumentScannerService {
/// Lance le scanner et renvoie le chemin de l'image redressée.
///
/// [pageLimit] est fixé à 1 (une seule cible par scan).
/// On désactive l'import galerie ici car la galerie est gérée séparément.
///
/// IMPORTANT : on utilise [ScannerMode.base] et NON [ScannerMode.full].
/// - `full`/`filter` appliquent des filtres "document" (noir & blanc,
/// rehaussement type photocopie) → détruit les couleurs de la cible.
/// - `base` ne fait QUE le recadrage + le redressement de perspective
/// (mise de face), en conservant l'image en couleurs réelles. C'est
/// indispensable pour pouvoir détecter ensuite les impacts.
Future<DocumentScanResult> scanTarget() async {
final options = DocumentScannerOptions(
mode: ScannerMode.base, // redressement seul, sans filtre couleur
pageLimit: 1,
isGalleryImport: false,
);
final scanner = DocumentScanner(options: options);
try {
final DocumentScanningResult result = await scanner.scanDocument();
final images = result.images;
if (images.isEmpty) {
return const DocumentScanResult(null);
}
return DocumentScanResult(images.first);
} finally {
// Libère les ressources natives du scanner.
await scanner.close();
}
}
}

View File

@@ -55,7 +55,37 @@ void _cropIsolateEntry(_CropParams params) {
throw Exception('Impossible de décoder l\'image: ${params.sourcePath}');
}
// ── OPTIMISATION MAJEURE : pré-réduction avant rotation ───────────────────
// La rotation (copyRotate) est l'opération la plus lourde du package `image`
// et son coût est proportionnel au nombre de pixels. Comme la sortie finale
// ne fait que `outputSize` px, on n'a aucun intérêt à faire pivoter une image
// de plusieurs dizaines de mégapixels.
//
// On réduit donc l'image source à une "taille de travail" juste suffisante
// AVANT toute rotation/crop. Le crop garde ~85% du cadre, donc on laisse une
// marge : workMax = outputSize / 0.7 couvre largement le besoin sans perte
// visible. Les coordonnées de crop étant relatives (0..1), elles restent
// valables quelle que soit l'échelle.
final int workMax = (params.outputSize / 0.7).round();
final int longestSide = math.max(originalImage.width, originalImage.height);
if (longestSide > workMax) {
if (originalImage.width >= originalImage.height) {
originalImage = img.copyResize(
originalImage,
width: workMax,
interpolation: img.Interpolation.linear,
);
} else {
originalImage = img.copyResize(
originalImage,
height: workMax,
interpolation: img.Interpolation.linear,
);
}
}
// Rotation si nécessaire — LINEAR au lieu de CUBIC pour la vitesse
// (désormais appliquée sur une image déjà réduite → beaucoup plus rapide)
if (params.rotationDegrees != 0.0) {
originalImage = img.copyRotate(
originalImage,