| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503 |
- import 'dart:io';
- import 'dart:async';
- import 'package:flutter/material.dart';
- import 'package:camera/camera.dart';
- import 'package:permission_handler/permission_handler.dart';
- import '../services/tflite_service.dart';
- import '../widgets/palm_bounding_box.dart';
- import '../constants/mpob_colors.dart';
- import '../services/app_strings.dart';
- import '../theme/locale_controller.dart';
- enum DetectionState { searching, locking, capturing, cooldown }
- class LiveAnalysisScreen extends StatefulWidget {
- const LiveAnalysisScreen({super.key});
- @override
- State<LiveAnalysisScreen> createState() => _LiveAnalysisScreenState();
- }
- class _LiveAnalysisScreenState extends State<LiveAnalysisScreen> {
- CameraController? _controller;
- final TfliteService _tfliteService = TfliteService();
- bool _isInitialized = false;
- bool _isProcessing = false;
- List<DetectionResult>? _detections;
-
- // Detection Lock Logic
- DetectionState _state = DetectionState.searching;
- static const double _lockThreshold = 0.60;
- // Frame Throttle removed: Atomic Idle Lock active.
-
- final List<bool> _detectionHistory = List.filled(15, false, growable: true); // 15 frames buffer
- static const int _requiredHits = 5; // Target hits for momentum lock
- int _currentHits = 0; // Track hits for the timer
- Timer? _lockTimer;
- Timer? _cooldownTimer;
- double _lockProgress = 0.0;
- bool _showFlash = false;
- // Raw CameraImage stream frames arrive in the sensor's native (landscape)
- // orientation, never pre-rotated for display — unlike CameraPreview's own
- // texture, which is rotated internally to appear upright. Detection boxes
- // decoded from the stream must be rotated by this same amount before
- // rendering, or they land in the wrong place/orientation relative to what's
- // shown on screen. Defaults to 90 (the standard rear-camera value on
- // Android) until the actual camera's sensorOrientation is known.
- int _sensorOrientation = 90;
- @override
- void initState() {
- super.initState();
- _initializeCamera();
- }
- Future<void> _initializeCamera() async {
- final status = await Permission.camera.request();
- if (status.isDenied) return;
- final cameras = await availableCameras();
- if (cameras.isEmpty) return;
- _sensorOrientation = cameras[0].sensorOrientation;
- _controller = CameraController(
- cameras[0],
- ResolutionPreset.medium, // Upgraded resolution for YOLO26 performance
- enableAudio: false,
- imageFormatGroup: Platform.isAndroid ? ImageFormatGroup.yuv420 : ImageFormatGroup.bgra8888,
- );
- try {
- await _controller!.initialize();
- await _tfliteService.initModel();
- _controller!.startImageStream(_handleImageStream);
- if (mounted) {
- setState(() {
- _isInitialized = true;
- });
- }
- } catch (e) {
- debugPrint("Camera init error: $e");
- }
- }
- void _handleImageStream(CameraImage image) {
- // Atomic Idle Lock: Only process if the Persistent Isolate is truly idle
- if (_isProcessing || _tfliteService.isIsolateBusy || _state == DetectionState.capturing || _state == DetectionState.cooldown) {
- return;
- }
- _processStreamFrame(image);
- }
- Future<void> _processStreamFrame(CameraImage image) async {
- setState(() => _isProcessing = true);
- try {
- final detections = _rotateDetections(await _tfliteService.runInferenceOnStream(image));
- bool currentFrameHasFruit = false;
- if (detections.isNotEmpty) {
- currentFrameHasFruit = detections.any((d) => d.confidence > _lockThreshold);
- }
- // Update Sliding Window Buffer
- _detectionHistory.removeAt(0);
- _detectionHistory.add(currentFrameHasFruit);
- _currentHits = _detectionHistory.where((h) => h).length;
- if (!mounted) return;
- setState(() {
- _detections = detections;
- });
- if (_state == DetectionState.searching) {
- if (_currentHits >= _requiredHits) {
- setState(() {
- _state = DetectionState.locking;
- _lockProgress = 0.0;
- });
- _startLockTimer();
- }
- }
- // Removed the old strict cancel logic.
- // _startLockTimer now safely handles momentum drain.
- } catch (e) {
- debugPrint("Stream processing error: $e");
- } finally {
- if (mounted) {
- setState(() => _isProcessing = false);
- }
- }
- }
- /// Rotates each detection's normalized box from raw sensor-space into
- /// display-space, matching the rotation CameraPreview's own texture
- /// undergoes internally. Confidence/class are untouched.
- List<DetectionResult> _rotateDetections(List<DetectionResult> detections) {
- final quarterTurns = (_sensorOrientation ~/ 90) % 4;
- if (quarterTurns == 0) return detections;
- return detections
- .map((d) => DetectionResult(
- className: d.className,
- classIndex: d.classIndex,
- confidence: d.confidence,
- normalizedBox: _rotateNormalizedRect(d.normalizedBox, quarterTurns),
- ))
- .toList();
- }
- /// Rotates an axis-aligned rect in normalized [0,1]x[0,1] space by
- /// [quarterTurns] * 90° clockwise.
- Rect _rotateNormalizedRect(Rect r, int quarterTurns) {
- switch (quarterTurns) {
- case 1: // 90° clockwise
- return Rect.fromLTRB(1 - r.bottom, r.left, 1 - r.top, r.right);
- case 2: // 180°
- return Rect.fromLTRB(1 - r.right, 1 - r.bottom, 1 - r.left, 1 - r.top);
- case 3: // 270° clockwise (90° counter-clockwise)
- return Rect.fromLTRB(r.top, 1 - r.right, r.bottom, 1 - r.left);
- default:
- return r;
- }
- }
- void _startLockTimer() {
- _lockTimer?.cancel();
- const duration = Duration(milliseconds: 100);
- int momentumTicks = 0;
- _lockTimer = Timer.periodic(duration, (timer) {
- if (!mounted) {
- timer.cancel();
- return;
- }
-
- // Momentum logic: add or subtract
- if (_currentHits >= _requiredHits) {
- momentumTicks++;
- } else {
- momentumTicks--;
- }
-
- if (momentumTicks < 0) momentumTicks = 0;
-
- setState(() {
- _lockProgress = (momentumTicks / 2.0).clamp(0.0, 1.0); // 2 ticks target
- });
-
- if (momentumTicks >= 2) {
- timer.cancel();
- if (_state == DetectionState.locking) {
- _triggerCapture();
- }
- } else if (momentumTicks <= 0 && _state == DetectionState.locking) {
- // Complete momentum loss -> Cancel lock
- timer.cancel();
- setState(() {
- _state = DetectionState.searching;
- _lockProgress = 0.0;
- });
- }
- });
- }
- Future<void> _triggerCapture() async {
- setState(() {
- _state = DetectionState.capturing;
- _lockProgress = 1.0;
- _showFlash = true;
- });
-
- // Quick 200ms white flash without blocking
- Future.delayed(const Duration(milliseconds: 200), () {
- if (mounted) setState(() => _showFlash = false);
- });
-
- await _captureAndAnalyze();
- }
- /// Live Inference is a quick verification tool, not a capture mode — the
- /// result is shown once and then discarded. Nothing is written to history;
- /// use Snap & Analyze if you want the result kept.
- Future<void> _captureAndAnalyze() async {
- if (_controller == null || !_controller!.value.isInitialized) return;
- // 1. Stop stream to avoid resource conflict
- await _controller!.stopImageStream();
- if (!mounted) return;
- try {
- // 2. Take high-res picture
- final XFile photo = await _controller!.takePicture();
- // 3. Run final inference on high-res
- final detections = await _tfliteService.runInference(photo.path);
- // 4. Discard the temp capture — nothing persists past this quick check.
- unawaited(File(photo.path).delete().catchError((_) => File(photo.path)));
- if (detections.isNotEmpty) {
- final best = detections.first;
- if (mounted) {
- await _showResultSheet(best.className, best.confidence);
- _startCooldown();
- }
- } else {
- if (mounted) {
- ScaffoldMessenger.of(context).showSnackBar(
- SnackBar(content: Text(AppStrings.t('live_no_detections_snackbar')))
- );
- _startCooldown();
- }
- }
- } catch (e) {
- debugPrint("Capture error: $e");
- if (mounted) _startCooldown();
- }
- }
-
- void _startCooldown() {
- if (!mounted) return;
- setState(() {
- _state = DetectionState.cooldown;
- _detections = null; // Clear boxes
- });
-
- // Clear detection history to ignore old hits
- _detectionHistory.fillRange(0, _detectionHistory.length, false);
- _cooldownTimer?.cancel();
- _cooldownTimer = Timer(const Duration(seconds: 3), () {
- if (!mounted) return;
- setState(() {
- _state = DetectionState.searching;
- });
- _controller?.startImageStream(_handleImageStream);
- });
- }
- Future<void> _showResultSheet(String ripenessClass, double confidence) async {
- final Color statusColor = getStatusColor(ripenessClass);
- final colorScheme = Theme.of(context).colorScheme;
- await showModalBottomSheet(
- context: context,
- isScrollControlled: true,
- isDismissible: false,
- enableDrag: false,
- shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20))),
- builder: (context) => Container(
- padding: const EdgeInsets.all(24),
- decoration: BoxDecoration(
- color: colorScheme.surfaceContainerHighest,
- borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
- ),
- child: Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- Icon(Icons.check_circle, color: statusColor, size: 64),
- const SizedBox(height: 16),
- Text(AppStrings.className(ripenessClass), style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold, color: colorScheme.onSurface)),
- Text(
- AppStrings.tp('confidence_label', {'percent': (confidence * 100).toStringAsFixed(1)}),
- style: TextStyle(color: colorScheme.onSurface.withValues(alpha: 0.6)),
- ),
- const SizedBox(height: 24),
- if (ripenessClass == 'Empty_Bunch' || ripenessClass == 'Abnormal')
- Container(
- padding: const EdgeInsets.all(12),
- decoration: BoxDecoration(color: Colors.red.shade50, borderRadius: BorderRadius.circular(8)),
- child: Text(AppStrings.t('health_alert_abnormal'), style: const TextStyle(color: Colors.red, fontWeight: FontWeight.bold)),
- ),
- const SizedBox(height: 24),
- SizedBox(
- width: double.infinity,
- child: ElevatedButton(
- onPressed: () => Navigator.pop(context),
- child: Text(AppStrings.t('done_button')),
- ),
- ),
- ],
- ),
- ),
- );
- }
- @override
- Widget build(BuildContext context) {
- return ValueListenableBuilder<String>(
- valueListenable: LocaleController.localeCode,
- builder: (context, _, _) => _buildScaffold(context),
- );
- }
- Widget _buildScaffold(BuildContext context) {
- if (!_isInitialized || _controller == null) {
- return const Scaffold(body: Center(child: CircularProgressIndicator()));
- }
- final isLockedVisual = _state == DetectionState.locking || _state == DetectionState.capturing;
- return Scaffold(
- backgroundColor: Colors.black,
- body: Stack(
- children: [
- // Camera Preview + Bounding Box Overlays. The overlay is passed as
- // CameraPreview's own `child` slot (not a separate sibling) so it's
- // measured against the exact same orientation-aware AspectRatio box
- // CameraPreview computes internally for the texture itself —
- // otherwise the overlay measures the full screen while the preview
- // is letterboxed/pillarboxed to the camera's real aspect ratio,
- // causing misaligned/stretched boxes.
- Center(
- child: CameraPreview(
- _controller!,
- child: _detections != null && _state != DetectionState.capturing && _state != DetectionState.cooldown
- ? LayoutBuilder(
- builder: (context, constraints) {
- return Stack(
- children: _detections!
- .map((d) => PalmBoundingBox(
- normalizedRect: d.normalizedBox,
- label: d.className,
- confidence: d.confidence,
- constraints: constraints,
- isLocked: (_state == DetectionState.locking || _state == DetectionState.capturing) && d.confidence > _lockThreshold,
- ))
- .toList(),
- );
- },
- )
- : null,
- ),
- ),
- // Top Info Bar
- Positioned(
- top: 40,
- left: 20,
- right: 20,
- child: Container(
- padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
- decoration: BoxDecoration(
- color: Colors.black54,
- borderRadius: BorderRadius.circular(20),
- ),
- child: Row(
- children: [
- Icon(
- _state == DetectionState.cooldown ? Icons.pause_circle_filled :
- isLockedVisual ? Icons.lock : Icons.center_focus_weak,
- color: _state == DetectionState.cooldown ? Colors.blue :
- isLockedVisual ? Colors.green : Colors.yellow,
- ),
- const SizedBox(width: 8),
- Text(
- _state == DetectionState.cooldown ? AppStrings.t('live_state_cooldown') :
- isLockedVisual ? AppStrings.t('live_state_locking') : AppStrings.t('live_state_searching'),
- style: TextStyle(
- color: _state == DetectionState.cooldown ? Colors.blue :
- isLockedVisual ? Colors.green : Colors.yellow,
- fontWeight: FontWeight.bold,
- ),
- ),
- const Spacer(),
- IconButton(
- icon: const Icon(Icons.close, color: Colors.white),
- onPressed: () => Navigator.pop(context),
- ),
- ],
- ),
- ),
- ),
- // Progress Overlay for Locking
- if (_state == DetectionState.locking)
- Positioned.fill(
- child: Center(
- child: SizedBox(
- width: 120,
- height: 120,
- child: TweenAnimationBuilder<double>(
- tween: Tween<double>(begin: 0.0, end: _lockProgress),
- duration: const Duration(milliseconds: 100),
- builder: (context, value, _) => CircularProgressIndicator(
- value: value,
- strokeWidth: 8,
- color: Colors.greenAccent,
- backgroundColor: Colors.white24,
- ),
- ),
- ),
- ),
- ),
- // White flash overlay
- Positioned.fill(
- child: IgnorePointer(
- child: AnimatedOpacity(
- opacity: _showFlash ? 1.0 : 0.0,
- duration: const Duration(milliseconds: 200),
- child: Container(color: Colors.white),
- ),
- ),
- ),
-
- if (_state == DetectionState.capturing && !_showFlash)
- Positioned.fill(
- child: Container(
- color: Colors.black45,
- child: const Center(
- child: CircularProgressIndicator(color: Colors.white),
- ),
- ),
- ),
-
- if (_state == DetectionState.cooldown)
- Positioned.fill(
- child: Container(
- color: Colors.black45,
- child: Center(
- child: Text(
- AppStrings.t('live_resuming_scan'),
- style: const TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold),
- ),
- ),
- ),
- ),
- // Bottom Hint
- if (_state == DetectionState.searching)
- Positioned(
- bottom: 40,
- left: 0,
- right: 0,
- child: Center(
- child: Text(
- AppStrings.t('live_hold_steady_hint'),
- style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w500),
- ),
- ),
- ),
- ],
- ),
- );
- }
- @override
- void dispose() {
- _lockTimer?.cancel();
- _cooldownTimer?.cancel();
- _controller?.dispose();
- _tfliteService.dispose();
- super.dispose();
- }
- }
|