Files
impact/lib/features/capture/capture_screen.dart
2026-06-05 22:06:05 +02:00

959 lines
34 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:io';
import 'dart:math' as math;
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:image_picker/image_picker.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:camera/camera.dart';
import 'package:provider/provider.dart';
import '../../core/constants/app_constants.dart';
import '../../core/theme/app_theme.dart';
import '../../data/models/target_type.dart';
import '../crop/crop_screen.dart';
import '../session/session_provider.dart';
import 'widgets/image_source_button.dart';
import '../../services/image_crop_service.dart';
import '../../services/opencv_target_service.dart';
import '../../services/parallelism_service.dart'; // NOUVEAU
import '../../services/object_detection_service.dart'; // NOUVEAU : ML Kit
import 'package:image/image.dart' as img;
import 'package:path_provider/path_provider.dart';
class CaptureScreen extends StatefulWidget {
const CaptureScreen({super.key});
@override
State<CaptureScreen> createState() => _CaptureScreenState();
}
class _CaptureScreenState extends State<CaptureScreen>
with SingleTickerProviderStateMixin {
final ImagePicker _picker = ImagePicker();
final ImageCropService _cropService = ImageCropService();
final TargetType _selectedType = TargetType.concentric;
final OpenCVTargetService _opencvService = OpenCVTargetService();
// NOUVEAU : Service IMU de parallélisme
final ParallelismService _parallelismService = ParallelismService(
alignThreshold: 25.0, // Seuil pour passer au vert (permissif)
misalignThreshold: 32.0, // Seuil pour repasser à l'orange
);
String? _selectedImagePath;
bool _isLoading = false;
// Caméra live
CameraController? _cameraController;
List<CameraDescription>? _cameras;
bool _isCameraInitialized = false;
bool _showLiveCamera = false;
// Animation pulsation du viseur
late AnimationController _scanAnimationController;
late Animation<double> _scanAnimation;
// Détection OpenCV (inchangée)
bool? _alignmentStatus;
Timer? _detectionTimer;
bool _isAnalyzingFrame = false;
// NOUVEAU : Données IMU en temps réel
ParallelismData? _parallelismData;
StreamSubscription<ParallelismData>? _parallelismSubscription;
// NOUVEAU : Détection d'objet ML Kit
final ObjectDetectionService _objectService = ObjectDetectionService();
StreamSubscription<List<DetectedObject2D>>? _objectSubscription;
List<DetectedObject2D> _detectedObjects = [];
@override
void initState() {
super.initState();
_scanAnimationController = AnimationController(
duration: const Duration(seconds: 2),
vsync: this,
)..repeat(reverse: true);
_scanAnimation = Tween<double>(begin: 0.8, end: 1.0).animate(
CurvedAnimation(parent: _scanAnimationController, curve: Curves.easeInOut),
);
}
@override
void dispose() {
_cameraController?.dispose();
_scanAnimationController.dispose();
_detectionTimer?.cancel();
_parallelismSubscription?.cancel(); // NOUVEAU
_parallelismService.dispose(); // NOUVEAU
_objectSubscription?.cancel(); // NOUVEAU
_objectService.dispose(); // NOUVEAU
super.dispose();
}
// ─────────────────────────────────────────────────────────────────────────
// NOUVEAU : Démarre l'écoute IMU et met à jour l'UI en temps réel
// ─────────────────────────────────────────────────────────────────────────
void _startParallelismDetection() {
_parallelismSubscription?.cancel();
_parallelismService.start();
_parallelismSubscription = _parallelismService.stream.listen((data) {
if (mounted && _showLiveCamera) {
setState(() {
_parallelismData = data;
});
}
});
}
void _stopParallelismDetection() {
_parallelismSubscription?.cancel();
_parallelismSubscription = null;
_parallelismService.stop();
_parallelismData = null;
}
// ─────────────────────────────────────────────────────────────────────────
// NOUVEAU : Démarre l'écoute de la détection d'objet ML Kit
// ─────────────────────────────────────────────────────────────────────────
void _startObjectDetection() {
_objectSubscription?.cancel();
_objectService.start();
_objectSubscription = _objectService.stream.listen((objects) {
if (mounted && _showLiveCamera) {
setState(() {
_detectedObjects = objects;
});
}
});
}
void _stopObjectDetection() {
_objectSubscription?.cancel();
_objectSubscription = null;
_objectService.stop();
_detectedObjects = [];
}
// ─────────────────────────────────────────────────────────────────────────
// NOUVEAU : Calcule la couleur et le message depuis les données IMU
// ─────────────────────────────────────────────────────────────────────────
/// Y a-t-il un objet détecté suffisamment grand et globalement centré ?
DetectedObject2D? get _primaryObject {
if (_detectedObjects.isEmpty) return null;
// On prend l'objet le plus grand (le sujet principal le plus probable).
final sorted = [..._detectedObjects]
..sort((a, b) => b.area.compareTo(a.area));
final obj = sorted.first;
// Filtre : doit occuper une part raisonnable de l'image et être centré.
final bool bigEnough = obj.area > 0.04; // ~20% × 20%
final bool centered = obj.centerX > 0.2 &&
obj.centerX < 0.8 &&
obj.centerY > 0.2 &&
obj.centerY < 0.8;
return (bigEnough && centered) ? obj : null;
}
bool get _objectReady => _primaryObject != null;
/// Couleur du cadre : dépend UNIQUEMENT du parallélisme IMU.
/// (La détection d'objet sert seulement à dessiner une boîte, elle ne
/// bloque jamais le passage au vert.)
Color get _frameColor {
final bool imuAligned =
_parallelismData == null || _parallelismData!.isAligned;
return imuAligned ? const Color(0xFF00FF00) : Colors.orange;
}
/// Message d'aide selon le parallélisme IMU.
String get _alignmentMessage {
if (_parallelismData == null ||
_parallelismData!.status == ParallelismStatus.unknown) {
return 'ALIGNEZ LA CIBLE DANS LE CADRE';
}
// Aligné → message de validation (avec bonus si un objet est cadré)
if (_parallelismData!.isAligned) {
return _objectReady
? 'PARFAIT — OBJET CADRÉ, PRÊT'
: 'PARALLÈLE OK — PRÊT À PHOTOGRAPHIER';
}
// Mal aligné → message directif selon l'axe le plus dévié
final double pitch = _parallelismData!.pitchDegrees;
final double roll = _parallelismData!.rollDegrees;
if (pitch.abs() >= roll.abs()) {
return pitch > 0
? 'INCLINEZ VERS L\'AVANT (${pitch.toStringAsFixed(1)}°)'
: 'INCLINEZ VERS L\'ARRIÈRE (${(-pitch).toStringAsFixed(1)}°)';
} else {
return roll > 0
? 'INCLINEZ VERS LA GAUCHE (${roll.toStringAsFixed(1)}°)'
: 'INCLINEZ VERS LA DROITE (${(-roll).toStringAsFixed(1)}°)';
}
}
// ─────────────────────────────────────────────────────────────────────────
// Initialisation de la caméra (inchangée, sauf ajout IMU)
// ─────────────────────────────────────────────────────────────────────────
Future<void> _initLiveCamera() async {
final status = await Permission.camera.request();
if (!status.isGranted) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Accès à l\'appareil photo requis')),
);
}
return;
}
setState(() => _isLoading = true);
try {
if (_cameraController != null) {
await _cameraController!.dispose();
_cameraController = null;
_isCameraInitialized = false;
}
_cameras = await availableCameras();
if (_cameras != null && _cameras!.isNotEmpty) {
_cameraController = CameraController(
_cameras![0],
ResolutionPreset.max,
enableAudio: false,
imageFormatGroup: ImageFormatGroup.jpeg,
);
await _cameraController!.initialize();
setState(() {
_isCameraInitialized = true;
_showLiveCamera = true;
_alignmentStatus = null;
_parallelismData = null; // NOUVEAU : reset IMU
});
_startAlignmentDetection();
_startParallelismDetection(); // NOUVEAU
_startObjectDetection(); // NOUVEAU
}
} catch (e) {
debugPrint('Erreur caméra: $e');
} finally {
setState(() => _isLoading = false);
}
}
// ─────────────────────────────────────────────────────────────────────────
// Détection OpenCV périodique (inchangée)
// ─────────────────────────────────────────────────────────────────────────
void _startAlignmentDetection() {
_detectionTimer?.cancel();
_detectionTimer = null;
DateTime? _lastAnalysis;
_cameraController!.startImageStream((CameraImage cameraImage) async {
// NOUVEAU : on alimente ML Kit à chaque frame (le service gère son propre
// throttle interne via _isBusy, donc pas de saturation).
_objectService.processCameraImage(
cameraImage,
_cameras![0],
DeviceOrientation.portraitUp,
);
if (_isAnalyzingFrame) return;
final now = DateTime.now();
if (_lastAnalysis != null &&
now.difference(_lastAnalysis!).inSeconds < 3) return;
_lastAnalysis = now;
_isAnalyzingFrame = true;
try {
final tempDir = await getTemporaryDirectory();
final tempPath = '${tempDir.path}/frame_analysis.jpg';
final img.Image? converted = _convertCameraImage(cameraImage);
if (converted == null) {
_isAnalyzingFrame = false;
return;
}
await File(tempPath).writeAsBytes(img.encodeJpg(converted, quality: 60));
final result = await _opencvService.detectTarget(tempPath);
try {
await File(tempPath).delete();
} catch (_) {}
if (mounted && _showLiveCamera) {
setState(() {
if (!result.success) {
_alignmentStatus = null;
} else {
final bool isCentered =
result.centerX > 0.25 && result.centerX < 0.75 &&
result.centerY > 0.25 && result.centerY < 0.75;
final bool isSizedCorrectly = result.radius > 0.15;
_alignmentStatus = isCentered && isSizedCorrectly;
}
});
}
} catch (e) {
debugPrint('Erreur analyse frame: $e');
} finally {
_isAnalyzingFrame = false;
}
});
}
void _stopAlignmentDetection() {
_detectionTimer?.cancel();
_detectionTimer = null;
try {
if (_cameraController != null &&
_cameraController!.value.isStreamingImages) {
_cameraController!.stopImageStream();
}
} catch (_) {}
_alignmentStatus = null;
}
img.Image? _convertCameraImage(CameraImage cameraImage) {
try {
final int width = cameraImage.width;
final int height = cameraImage.height;
final int targetW = width ~/ 2;
final int targetH = height ~/ 2;
final yPlane = cameraImage.planes[0];
final uPlane = cameraImage.planes[1];
final vPlane = cameraImage.planes[2];
final image = img.Image(width: targetW, height: targetH);
for (int y = 0; y < targetH; y++) {
for (int x = 0; x < targetW; x++) {
final int srcX = x * 2;
final int srcY = y * 2;
final int yIndex = srcY * yPlane.bytesPerRow + srcX;
final int uvIndex = (srcY ~/ 2) * uPlane.bytesPerRow +
(srcX ~/ 2) * uPlane.bytesPerPixel!;
final int yVal = yPlane.bytes[yIndex];
final int uVal = uPlane.bytes[uvIndex] - 128;
final int vVal = vPlane.bytes[uvIndex] - 128;
final int r = (yVal + 1.402 * vVal).clamp(0, 255).toInt();
final int g = (yVal - 0.344136 * uVal - 0.714136 * vVal)
.clamp(0, 255)
.toInt();
final int b = (yVal + 1.772 * uVal).clamp(0, 255).toInt();
image.setPixelRgb(x, y, r, g, b);
}
}
return image;
} catch (e) {
debugPrint('Erreur conversion frame: $e');
return null;
}
}
// ─────────────────────────────────────────────────────────────────────────
// Galerie (inchangée)
// ─────────────────────────────────────────────────────────────────────────
Future<void> _handleGallerySelection() async {
PermissionStatus status;
if (Platform.isAndroid) {
final deviceInfo = DeviceInfoPlugin();
final androidInfo = await deviceInfo.androidInfo;
status = androidInfo.version.sdkInt >= 33
? await Permission.photos.request()
: await Permission.storage.request();
} else {
status = await Permission.photos.request();
}
if (status.isGranted) {
_captureImageFromGallery();
} else if (status.isPermanentlyDenied) {
_showSettingsDialog();
} else {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Accès à la galerie requis pour continuer'),
),
);
}
}
}
void _showSettingsDialog() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Permission requise'),
content: const Text(
'L\'accès aux photos est nécessaire. Veuillez l\'activer dans les paramètres.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annuler'),
),
TextButton(
onPressed: () => openAppSettings(),
child: const Text('Paramètres'),
),
],
),
);
}
// ─────────────────────────────────────────────────────────────────────────
// Build principal
// ─────────────────────────────────────────────────────────────────────────
@override
Widget build(BuildContext context) {
final sessionProvider = context.watch<SessionProvider>();
final title = sessionProvider.isSessionActive
? 'Cible ${sessionProvider.targetCount + 1}'
: 'Source';
if (_showLiveCamera &&
_cameraController != null &&
_cameraController!.value.isInitialized) {
return _buildLiveCameraView();
}
return Scaffold(
appBar: AppBar(title: Text(title)),
body: SingleChildScrollView(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: AppConstants.largePadding),
_buildSectionTitle('Source de l\'Image'),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: ImageSourceButton(
icon: Icons.camera_alt,
label: 'Scanner',
onPressed: _isLoading ? null : _initLiveCamera,
),
),
const SizedBox(width: 12),
Expanded(
child: ImageSourceButton(
icon: Icons.photo_library,
label: 'Galerie',
onPressed: _isLoading ? null : _handleGallerySelection,
),
),
],
),
const SizedBox(height: AppConstants.largePadding),
if (_isLoading)
const Center(
child: Padding(
padding: EdgeInsets.all(32),
child: CircularProgressIndicator(),
),
)
else
_buildGuide(),
],
),
),
);
}
// ─────────────────────────────────────────────────────────────────────────
// Vue caméra live — MODIFIÉE pour utiliser les données IMU
// ─────────────────────────────────────────────────────────────────────────
Widget _buildLiveCameraView() {
// Priorité : IMU (parallélisme physique) prime sur OpenCV (centrage)
// Si l'IMU dit "mal aligné" → orange, sinon → vert
final Color frameColor = _frameColor;
final String alignmentMessage = _alignmentMessage;
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
fit: StackFit.expand,
children: [
// 1. Flux vidéo caméra
Center(
child: AspectRatio(
aspectRatio: 3 / 4,
child: CameraPreview(_cameraController!),
),
),
// NOUVEAU : 1.bis Cadre dessiné autour de l'objet détecté
if (_primaryObject != null)
Center(
child: AspectRatio(
aspectRatio: 3 / 4,
child: CustomPaint(
painter: _ObjectBoxPainter(
object: _primaryObject!,
color: frameColor,
),
),
),
),
// 2. Cadre de visée avec coins et mire centrale
Center(
child: Container(
width: MediaQuery.of(context).size.width * 0.85,
height: MediaQuery.of(context).size.width * 0.85,
decoration: BoxDecoration(
border: Border.all(color: frameColor, width: 2.0),
borderRadius: BorderRadius.circular(16),
),
child: Stack(
children: [
_buildCameraCorner(TopLeft: true, color: frameColor),
_buildCameraCorner(TopRight: true, color: frameColor),
_buildCameraCorner(BottomLeft: true, color: frameColor),
_buildCameraCorner(BottomRight: true, color: frameColor),
Center(
child: Container(
width: 24,
height: 24,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: frameColor, width: 1.2),
),
child: Stack(
children: [
Center(
child: Container(
width: 20,
height: 1.2,
color: frameColor,
),
),
Center(
child: Container(
width: 1.2,
height: 20,
color: frameColor,
),
),
],
),
),
),
],
),
),
),
// 3. Bouton retour
Positioned(
top: 40,
left: 20,
child: CircleAvatar(
backgroundColor: Colors.black54,
child: IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: () {
_stopAlignmentDetection();
_stopParallelismDetection(); // NOUVEAU
_stopObjectDetection(); // NOUVEAU
setState(() {
_showLiveCamera = false;
});
},
),
),
),
// 4. Message d'alignement principal (texte dynamique IMU)
Positioned(
top: 50,
left: 0,
right: 0,
child: Center(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(20),
),
child: Text(
alignmentMessage,
style: TextStyle(
color: frameColor,
fontWeight: FontWeight.bold,
fontSize: 12,
letterSpacing: 1.2,
),
),
),
),
),
// NOUVEAU : Indicateur visuel des angles en bas à gauche
if (_parallelismData != null &&
_parallelismData!.status != ParallelismStatus.unknown)
Positioned(
bottom: 140,
left: 16,
child: _buildAngleIndicator(_parallelismData!),
),
// Indicateur d'analyse en cours
if (_isAnalyzingFrame)
Positioned(
top: 45,
right: 20,
child: Container(
width: 10,
height: 10,
decoration: const BoxDecoration(
color: Colors.white54,
shape: BoxShape.circle,
),
),
),
// 5. Bouton déclencheur
Positioned(
bottom: 40,
left: 0,
right: 0,
child: Center(
child: GestureDetector(
onTap: _takePictureManually,
child: Container(
height: 80,
width: 80,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 4),
),
child: Center(
child: Container(
height: 64,
width: 64,
decoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
),
),
),
),
),
),
),
],
),
);
}
// ─────────────────────────────────────────────────────────────────────────
// NOUVEAU : Widget affichant les angles pitch/roll en temps réel
// ─────────────────────────────────────────────────────────────────────────
Widget _buildAngleIndicator(ParallelismData data) {
final Color color = data.isAligned ? const Color(0xFF00FF00) : Colors.orange;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: color.withValues(alpha: 0.5), width: 1),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
_buildAngleRow(
label: 'Pitch',
value: data.pitchDegrees,
color: color,
),
const SizedBox(height: 2),
_buildAngleRow(
label: 'Roll ',
value: data.rollDegrees,
color: color,
),
],
),
);
}
Widget _buildAngleRow({
required String label,
required double value,
required Color color,
}) {
final String sign = value >= 0 ? '+' : '';
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'$label: ',
style: const TextStyle(
color: Colors.white60,
fontSize: 11,
fontFamily: 'monospace',
),
),
Text(
'$sign${value.toStringAsFixed(1)}°',
style: TextStyle(
color: color,
fontSize: 11,
fontWeight: FontWeight.bold,
fontFamily: 'monospace',
),
),
],
);
}
// ─────────────────────────────────────────────────────────────────────────
// Coins du cadre (inchangés)
// ─────────────────────────────────────────────────────────────────────────
Widget _buildCameraCorner({
bool TopLeft = false,
bool TopRight = false,
bool BottomLeft = false,
bool BottomRight = false,
Color color = const Color(0xFF00FF00),
}) {
return Positioned(
top: (TopLeft || TopRight) ? 10 : null,
bottom: (BottomLeft || BottomRight) ? 10 : null,
left: (TopLeft || BottomLeft) ? 10 : null,
right: (TopRight || BottomRight) ? 10 : null,
child: Container(
width: 20,
height: 20,
decoration: BoxDecoration(
border: Border(
top: (TopLeft || TopRight)
? BorderSide(color: color, width: 4)
: BorderSide.none,
bottom: (BottomLeft || BottomRight)
? BorderSide(color: color, width: 4)
: BorderSide.none,
left: (TopLeft || BottomLeft)
? BorderSide(color: color, width: 4)
: BorderSide.none,
right: (TopRight || BottomRight)
? BorderSide(color: color, width: 4)
: BorderSide.none,
),
),
),
);
}
// ─────────────────────────────────────────────────────────────────────────
// Capture manuelle (inchangée, sauf ajout stop IMU)
// ─────────────────────────────────────────────────────────────────────────
Future<void> _takePictureManually() async {
if (_cameraController == null || !_cameraController!.value.isInitialized) {
return;
}
setState(() => _isLoading = true);
try {
try {
await _cameraController!.setZoomLevel(1.0);
} catch (_) {}
_stopAlignmentDetection();
_stopParallelismDetection(); // NOUVEAU
_stopObjectDetection(); // NOUVEAU
final XFile photo = await _cameraController!.takePicture();
setState(() {
_selectedImagePath = photo.path;
_showLiveCamera = false;
});
_analyzeImage();
} catch (e) {
debugPrint('Erreur lors du clic photo: $e');
} finally {
setState(() => _isLoading = false);
}
}
// ─────────────────────────────────────────────────────────────────────────
// Helpers UI (inchangés)
// ─────────────────────────────────────────────────────────────────────────
Widget _buildSectionTitle(String title) {
return Text(
title,
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(fontWeight: FontWeight.bold),
);
}
Widget _buildGuide() {
return Card(
child: Padding(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
child: Column(
children: [
Icon(Icons.help_outline, size: 48, color: Colors.grey[400]),
const SizedBox(height: 12),
Text(
'Conseils pour une bonne analyse',
style: Theme.of(context)
.textTheme
.titleSmall
?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 12),
_buildGuideItem(
Icons.crop_free,
'Cadrez la cible entière dans l\'image',
),
_buildGuideItem(Icons.wb_sunny, 'Utilisez un bon éclairage'),
_buildGuideItem(Icons.straighten, 'Prenez la photo de face'),
_buildGuideItem(Icons.blur_off, 'Évitez les images floues'),
_buildGuideItem(
Icons.cleaning_services,
'Nettoyer votre objectif avec une chiffonnette',
),
],
),
),
);
}
Widget _buildGuideItem(IconData icon, String text) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
Icon(icon, size: 20, color: AppTheme.primaryColor),
const SizedBox(width: 12),
Expanded(child: Text(text)),
],
),
);
}
Future<void> _captureImageFromGallery() async {
setState(() => _isLoading = true);
try {
final XFile? image = await _picker.pickImage(
source: ImageSource.gallery,
maxWidth: 2048,
maxHeight: 2048,
imageQuality: 90,
);
if (image != null) {
setState(() {
_selectedImagePath = image.path;
});
_analyzeImage();
}
} catch (e) {
debugPrint('Erreur galerie: $e');
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
void _analyzeImage() {
if (_selectedImagePath == null) return;
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => CropScreen(
imagePath: _selectedImagePath!,
targetType: _selectedType,
),
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────
// NOUVEAU : Painter qui dessine la boîte englobante de l'objet détecté.
// Les coordonnées de l'objet sont normalisées (0..1) → on les multiplie
// par la taille réelle de la zone de dessin (qui suit l'AspectRatio 3/4).
// ─────────────────────────────────────────────────────────────────────────
class _ObjectBoxPainter extends CustomPainter {
final DetectedObject2D object;
final Color color;
_ObjectBoxPainter({required this.object, required this.color});
@override
void paint(Canvas canvas, Size size) {
final Paint stroke = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 3.0
..color = color;
final Rect rect = Rect.fromLTRB(
object.left * size.width,
object.top * size.height,
object.right * size.width,
object.bottom * size.height,
);
// Boîte à coins arrondis
final RRect rrect =
RRect.fromRectAndRadius(rect, const Radius.circular(8));
canvas.drawRRect(rrect, stroke);
// Petit label si disponible
if (object.label.isNotEmpty) {
final textPainter = TextPainter(
text: TextSpan(
text: ' ${object.label} '
'${(object.confidence * 100).toStringAsFixed(0)}% ',
style: TextStyle(
color: Colors.black,
fontSize: 12,
fontWeight: FontWeight.bold,
backgroundColor: color,
),
),
textDirection: TextDirection.ltr,
)..layout();
textPainter.paint(
canvas,
Offset(rect.left, (rect.top - 18).clamp(0.0, size.height)),
);
}
}
@override
bool shouldRepaint(_ObjectBoxPainter old) =>
old.object != object || old.color != color;
}