feat: reintegration (en cours) de ml kit lecteur de doc
This commit is contained in:
@@ -37,6 +37,9 @@
|
|||||||
<meta-data
|
<meta-data
|
||||||
android:name="flutterEmbedding"
|
android:name="flutterEmbedding"
|
||||||
android:value="2" />
|
android:value="2" />
|
||||||
|
<meta-data
|
||||||
|
android:name="com.google.mlkit.vision.DEPENDENCIES"
|
||||||
|
android:value="docscanner" />
|
||||||
</application>
|
</application>
|
||||||
<!-- Required to query activities that can process text, see:
|
<!-- Required to query activities that can process text, see:
|
||||||
https://developer.android.com/training/package-visibility and
|
https://developer.android.com/training/package-visibility and
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import 'widgets/image_source_button.dart';
|
|||||||
import '../../services/image_crop_service.dart';
|
import '../../services/image_crop_service.dart';
|
||||||
import '../../services/opencv_target_service.dart';
|
import '../../services/opencv_target_service.dart';
|
||||||
import '../../services/parallelism_service.dart'; // NOUVEAU
|
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:image/image.dart' as img;
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
@@ -36,10 +37,13 @@ class _CaptureScreenState extends State<CaptureScreen>
|
|||||||
|
|
||||||
// NOUVEAU : Service IMU de parallélisme
|
// NOUVEAU : Service IMU de parallélisme
|
||||||
final ParallelismService _parallelismService = ParallelismService(
|
final ParallelismService _parallelismService = ParallelismService(
|
||||||
alignThreshold: 15.0, // Seuil pour passer au vert
|
alignThreshold: 25.0, // Seuil pour passer au vert (permissif)
|
||||||
misalignThreshold: 22.0, // Seuil pour repasser à l'orange
|
misalignThreshold: 32.0, // Seuil pour repasser à l'orange
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// NOUVEAU : Scanner de documents Google (redresse la cible de face)
|
||||||
|
final DocumentScannerService _docScanner = DocumentScannerService();
|
||||||
|
|
||||||
String? _selectedImagePath;
|
String? _selectedImagePath;
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
|
|
||||||
@@ -53,8 +57,9 @@ class _CaptureScreenState extends State<CaptureScreen>
|
|||||||
late AnimationController _scanAnimationController;
|
late AnimationController _scanAnimationController;
|
||||||
late Animation<double> _scanAnimation;
|
late Animation<double> _scanAnimation;
|
||||||
|
|
||||||
// Détection OpenCV (inchangée)
|
// Détection OpenCV (cible circulaire) — on garde le résultat COMPLET
|
||||||
bool? _alignmentStatus;
|
bool? _alignmentStatus;
|
||||||
|
TargetDetectionResult? _targetResult; // centre + rayon de la cible
|
||||||
Timer? _detectionTimer;
|
Timer? _detectionTimer;
|
||||||
bool _isAnalyzingFrame = false;
|
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.
|
/// La cible circulaire est-elle détectée et raisonnablement cadrée ?
|
||||||
Color get _frameColor {
|
bool get _targetReady {
|
||||||
if (_parallelismData == null) return const Color(0xFF00FF00);
|
final t = _targetResult;
|
||||||
return _parallelismData!.isAligned ? const Color(0xFF00FF00) : Colors.orange;
|
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 {
|
String get _alignmentMessage {
|
||||||
if (_parallelismData == null) {
|
if (_parallelismData == null ||
|
||||||
|
_parallelismData!.status == ParallelismStatus.unknown) {
|
||||||
return 'ALIGNEZ LA CIBLE DANS LE CADRE';
|
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 pitch = _parallelismData!.pitchDegrees;
|
||||||
final double roll = _parallelismData!.rollDegrees;
|
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 {
|
Future<void> _initLiveCamera() async {
|
||||||
final status = await Permission.camera.request();
|
final status = await Permission.camera.request();
|
||||||
@@ -168,7 +187,7 @@ class _CaptureScreenState extends State<CaptureScreen>
|
|||||||
if (_cameras != null && _cameras!.isNotEmpty) {
|
if (_cameras != null && _cameras!.isNotEmpty) {
|
||||||
_cameraController = CameraController(
|
_cameraController = CameraController(
|
||||||
_cameras![0],
|
_cameras![0],
|
||||||
ResolutionPreset.max,
|
ResolutionPreset.veryHigh, // ~1080p : suffisant et bien plus rapide que max
|
||||||
enableAudio: false,
|
enableAudio: false,
|
||||||
imageFormatGroup: ImageFormatGroup.jpeg,
|
imageFormatGroup: ImageFormatGroup.jpeg,
|
||||||
);
|
);
|
||||||
@@ -179,6 +198,7 @@ class _CaptureScreenState extends State<CaptureScreen>
|
|||||||
_isCameraInitialized = true;
|
_isCameraInitialized = true;
|
||||||
_showLiveCamera = true;
|
_showLiveCamera = true;
|
||||||
_alignmentStatus = null;
|
_alignmentStatus = null;
|
||||||
|
_targetResult = null;
|
||||||
_parallelismData = null; // NOUVEAU : reset IMU
|
_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() {
|
void _startAlignmentDetection() {
|
||||||
_detectionTimer?.cancel();
|
_detectionTimer?.cancel();
|
||||||
@@ -204,8 +224,9 @@ class _CaptureScreenState extends State<CaptureScreen>
|
|||||||
_cameraController!.startImageStream((CameraImage cameraImage) async {
|
_cameraController!.startImageStream((CameraImage cameraImage) async {
|
||||||
if (_isAnalyzingFrame) return;
|
if (_isAnalyzingFrame) return;
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
|
// Cadence ~1 s : assez réactif pour suivre la cible sans saturer le CPU.
|
||||||
if (_lastAnalysis != null &&
|
if (_lastAnalysis != null &&
|
||||||
now.difference(_lastAnalysis!).inSeconds < 3) return;
|
now.difference(_lastAnalysis!).inMilliseconds < 1000) return;
|
||||||
_lastAnalysis = now;
|
_lastAnalysis = now;
|
||||||
_isAnalyzingFrame = true;
|
_isAnalyzingFrame = true;
|
||||||
|
|
||||||
@@ -229,6 +250,8 @@ class _CaptureScreenState extends State<CaptureScreen>
|
|||||||
|
|
||||||
if (mounted && _showLiveCamera) {
|
if (mounted && _showLiveCamera) {
|
||||||
setState(() {
|
setState(() {
|
||||||
|
_targetResult = result.success ? result : null;
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
_alignmentStatus = null;
|
_alignmentStatus = null;
|
||||||
} else {
|
} else {
|
||||||
@@ -258,6 +281,7 @@ class _CaptureScreenState extends State<CaptureScreen>
|
|||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
_alignmentStatus = null;
|
_alignmentStatus = null;
|
||||||
|
_targetResult = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
img.Image? _convertCameraImage(CameraImage cameraImage) {
|
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() {
|
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 Color frameColor = _frameColor;
|
||||||
final String alignmentMessage = _alignmentMessage;
|
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
|
// 2. Cadre de visée avec coins et mire centrale
|
||||||
Center(
|
Center(
|
||||||
child: Container(
|
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(
|
Positioned(
|
||||||
bottom: 40,
|
bottom: 40,
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
child: Center(
|
child: Center(
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: _takePictureManually,
|
onTap: _scanWithDocumentScanner,
|
||||||
child: Container(
|
child: Container(
|
||||||
height: 80,
|
height: 80,
|
||||||
width: 80,
|
width: 80,
|
||||||
@@ -578,6 +615,7 @@ class _CaptureScreenState extends State<CaptureScreen>
|
|||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
shape: BoxShape.circle,
|
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 {
|
Future<void> _scanWithDocumentScanner() async {
|
||||||
if (_cameraController == null || !_cameraController!.value.isInitialized) {
|
// On stoppe la caméra custom : le scanner Google ouvre sa propre caméra.
|
||||||
return;
|
_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 {
|
||||||
try {
|
await _cameraController?.dispose();
|
||||||
await _cameraController!.setZoomLevel(1.0);
|
_cameraController = null;
|
||||||
} catch (_) {}
|
_isCameraInitialized = false;
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
_stopAlignmentDetection();
|
try {
|
||||||
_stopParallelismDetection(); // NOUVEAU
|
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(() {
|
setState(() {
|
||||||
_selectedImagePath = photo.path;
|
_selectedImagePath = result.imagePath;
|
||||||
_showLiveCamera = false;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
_analyzeImage();
|
_analyzeImage();
|
||||||
} catch (e) {
|
} 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 {
|
} finally {
|
||||||
setState(() => _isLoading = false);
|
if (mounted) setState(() => _isLoading = false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -817,3 +872,61 @@ 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;
|
||||||
|
}
|
||||||
47
lib/services/document_scanner_service.dart
Normal file
47
lib/services/document_scanner_service.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user