feat: reintegration (en cours) de ml kit lecteur de doc

This commit is contained in:
qguillaume
2026-06-06 01:32:16 +02:00
parent 18e591f3fc
commit 23c3bb178f
3 changed files with 201 additions and 38 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

@@ -17,6 +17,7 @@ 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/document_scanner_service.dart'; // NOUVEAU : scanner doc redresse la cible
import 'package:image/image.dart' as img;
import 'package:path_provider/path_provider.dart';
@@ -36,10 +37,13 @@ class _CaptureScreenState extends State<CaptureScreen>
// NOUVEAU : Service IMU de parallélisme
final ParallelismService _parallelismService = ParallelismService(
alignThreshold: 15.0, // Seuil pour passer au vert
misalignThreshold: 22.0, // Seuil pour repasser à l'orange
alignThreshold: 25.0, // Seuil pour passer au vert (permissif)
misalignThreshold: 32.0, // Seuil pour repasser à l'orange
);
// NOUVEAU : Scanner de documents Google (redresse la cible de face)
final DocumentScannerService _docScanner = DocumentScannerService();
String? _selectedImagePath;
bool _isLoading = false;
@@ -53,8 +57,9 @@ class _CaptureScreenState extends State<CaptureScreen>
late AnimationController _scanAnimationController;
late Animation<double> _scanAnimation;
// Détection OpenCV (inchangée)
// Détection OpenCV (cible circulaire) — on garde le résultat COMPLET
bool? _alignmentStatus;
TargetDetectionResult? _targetResult; // centre + rayon de la cible
Timer? _detectionTimer;
bool _isAnalyzingFrame = false;
@@ -109,25 +114,39 @@ class _CaptureScreenState extends State<CaptureScreen>
}
// ─────────────────────────────────────────────────────────────────────────
// NOUVEAU : Calcule la couleur et le message depuis les données IMU
// Calcule la couleur et le message depuis l'IMU + la détection de cible
// ─────────────────────────────────────────────────────────────────────────
/// 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;
/// 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;
}
/// Message d'aide selon le parallélisme détecté.
/// Couleur du cadre : dépend UNIQUEMENT du parallélisme IMU.
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) {
if (_parallelismData == null ||
_parallelismData!.status == ParallelismStatus.unknown) {
return 'ALIGNEZ LA CIBLE DANS LE CADRE';
}
if (_parallelismData!.status == ParallelismStatus.aligned) {
return 'PARALLÈLE OK — PRÊT À PHOTOGRAPHIER';
if (_parallelismData!.isAligned) {
return _targetReady
? 'PARFAIT — CIBLE DÉTECTÉE, PRÊT'
: 'PARALLÈLE OK — PRÊT À SCANNER';
}
// Message directif selon l'axe le plus dévié
final double pitch = _parallelismData!.pitchDegrees;
final double roll = _parallelismData!.rollDegrees;
@@ -143,7 +162,7 @@ class _CaptureScreenState extends State<CaptureScreen>
}
// ─────────────────────────────────────────────────────────────────────────
// Initialisation de la caméra (inchangée, sauf ajout IMU)
// Initialisation de la caméra
// ─────────────────────────────────────────────────────────────────────────
Future<void> _initLiveCamera() async {
final status = await Permission.camera.request();
@@ -168,7 +187,7 @@ class _CaptureScreenState extends State<CaptureScreen>
if (_cameras != null && _cameras!.isNotEmpty) {
_cameraController = CameraController(
_cameras![0],
ResolutionPreset.max,
ResolutionPreset.veryHigh, // ~1080p : suffisant et bien plus rapide que max
enableAudio: false,
imageFormatGroup: ImageFormatGroup.jpeg,
);
@@ -179,6 +198,7 @@ class _CaptureScreenState extends State<CaptureScreen>
_isCameraInitialized = true;
_showLiveCamera = true;
_alignmentStatus = null;
_targetResult = null;
_parallelismData = null; // NOUVEAU : reset IMU
});
@@ -193,7 +213,7 @@ class _CaptureScreenState extends State<CaptureScreen>
}
// ─────────────────────────────────────────────────────────────────────────
// Détection OpenCV périodique (inchangée)
// Détection OpenCV périodique (cercle de cible en direct)
// ─────────────────────────────────────────────────────────────────────────
void _startAlignmentDetection() {
_detectionTimer?.cancel();
@@ -204,8 +224,9 @@ class _CaptureScreenState extends State<CaptureScreen>
_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!).inSeconds < 3) return;
now.difference(_lastAnalysis!).inMilliseconds < 1000) return;
_lastAnalysis = now;
_isAnalyzingFrame = true;
@@ -229,6 +250,8 @@ class _CaptureScreenState extends State<CaptureScreen>
if (mounted && _showLiveCamera) {
setState(() {
_targetResult = result.success ? result : null;
if (!result.success) {
_alignmentStatus = null;
} else {
@@ -258,6 +281,7 @@ class _CaptureScreenState extends State<CaptureScreen>
}
} catch (_) {}
_alignmentStatus = null;
_targetResult = null;
}
img.Image? _convertCameraImage(CameraImage cameraImage) {
@@ -418,11 +442,9 @@ class _CaptureScreenState extends State<CaptureScreen>
}
// ─────────────────────────────────────────────────────────────────────────
// Vue caméra live — MODIFIÉE pour utiliser les données IMU
// Vue caméra live (aide au cadrage : parallélisme + cercle de détection)
// ─────────────────────────────────────────────────────────────────────────
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;
@@ -439,6 +461,21 @@ class _CaptureScreenState extends State<CaptureScreen>
),
),
// 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(
@@ -555,14 +592,14 @@ class _CaptureScreenState extends State<CaptureScreen>
),
),
// 5. Bouton déclencheur
// 5. Bouton déclencheur → lance le scanner de documents Google
Positioned(
bottom: 40,
left: 0,
right: 0,
child: Center(
child: GestureDetector(
onTap: _takePictureManually,
onTap: _scanWithDocumentScanner,
child: Container(
height: 80,
width: 80,
@@ -578,6 +615,7 @@ class _CaptureScreenState extends State<CaptureScreen>
color: Colors.white,
shape: BoxShape.circle,
),
child: const Icon(Icons.document_scanner, color: Colors.black54),
),
),
),
@@ -691,34 +729,51 @@ class _CaptureScreenState extends State<CaptureScreen>
}
// ─────────────────────────────────────────────────────────────────────────
// Capture manuelle (inchangée, sauf ajout stop IMU)
// NOUVEAU : Déclic → lance le scanner de documents Google qui redresse
// automatiquement la cible (feuille rectangulaire) de face.
// ─────────────────────────────────────────────────────────────────────────
Future<void> _takePictureManually() async {
if (_cameraController == null || !_cameraController!.value.isInitialized) {
return;
}
Future<void> _scanWithDocumentScanner() async {
// On stoppe la caméra custom : le scanner Google ouvre sa propre caméra.
_stopAlignmentDetection();
_stopParallelismDetection();
setState(() => _isLoading = true);
setState(() {
_isLoading = true;
_showLiveCamera = false;
});
// Libère la caméra custom pour éviter tout conflit d'accès au capteur.
try {
try {
await _cameraController!.setZoomLevel(1.0);
} catch (_) {}
await _cameraController?.dispose();
_cameraController = null;
_isCameraInitialized = false;
} catch (_) {}
_stopAlignmentDetection();
_stopParallelismDetection(); // NOUVEAU
try {
final result = await _docScanner.scanTarget();
final XFile photo = await _cameraController!.takePicture();
if (!result.success) {
// L'utilisateur a annulé le scan : on ne fait rien de plus.
if (mounted) setState(() => _isLoading = false);
return;
}
setState(() {
_selectedImagePath = photo.path;
_showLiveCamera = false;
_selectedImagePath = result.imagePath;
});
_analyzeImage();
} catch (e) {
debugPrint('Erreur lors du clic photo: $e');
debugPrint('Erreur scanner document: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Le scan a échoué. Réessayez.'),
),
);
}
} finally {
setState(() => _isLoading = false);
if (mounted) setState(() => _isLoading = false);
}
}
@@ -816,4 +871,62 @@ class _CaptureScreenState extends State<CaptureScreen>
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────
// Painter qui dessine un cercle autour de la cible circulaire détectée.
// ─────────────────────────────────────────────────────────────────────────
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) {
final double cx = target.centerX * size.width;
final double cy = target.centerY * size.height;
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;
canvas.drawCircle(Offset(cx, cy), r, stroke);
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);
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

@@ -0,0 +1,47 @@
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 caméra custom gère déjà ce flux.
Future<DocumentScanResult> scanTarget() async {
final options = DocumentScannerOptions(
mode: ScannerMode.full, // recadrage + filtres + redressement complets
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();
}
}
}