| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500 |
- import 'dart:io';
- import 'dart:ui';
- import 'dart:async';
- import 'package:flutter/material.dart';
- import 'package:camera/camera.dart';
- import 'package:permission_handler/permission_handler.dart';
- import 'package:path_provider/path_provider.dart';
- import 'package:path/path.dart' as p;
- import '../services/tflite_service.dart';
- import '../services/database_helper.dart';
- import '../models/palm_record.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();
- final DatabaseHelper _dbHelper = DatabaseHelper();
- bool _isInitialized = false;
- bool _isProcessing = false;
- int _frameCount = 0;
- List<DetectionResult>? _detections;
-
- // Detection Lock Logic
- DetectionState _state = DetectionState.searching;
- static const double _lockThreshold = 0.60;
- static const int _frameThrottle = 2; // Check frames more frequently
-
- final List<bool> _detectionHistory = List.filled(20, false, growable: true); // 20 frames buffer
- static const int _requiredHits = 4; // Threshold for momentum ticks
- int _currentHits = 0; // Track hits for the timer
- Timer? _lockTimer;
- Timer? _cooldownTimer;
- double _lockProgress = 0.0;
- bool _showFlash = false;
- @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;
- _controller = CameraController(
- cameras[0],
- ResolutionPreset.low, // Downgraded resolution for 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) {
- print("Camera init error: $e");
- }
- }
- void _handleImageStream(CameraImage image) {
- if (_isProcessing || _state == DetectionState.capturing || _state == DetectionState.cooldown) return;
- _frameCount++;
- if (_frameCount % _frameThrottle != 0) return;
- _processStreamFrame(image);
- }
- Future<void> _processStreamFrame(CameraImage image) async {
- setState(() => _isProcessing = true);
- try {
- final detections = 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) {
- print("Stream processing error: $e");
- } finally {
- if (mounted) {
- setState(() => _isProcessing = false);
- }
- }
- }
- 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 / 3.0).clamp(0.0, 1.0); // 3 ticks target
- });
-
- if (momentumTicks >= 3) {
- 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;
- });
- }
- });
- }
- void _cancelLockTimer() {
- _lockTimer?.cancel();
- _lockTimer = null;
- }
- 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();
- }
- 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);
- if (detections.isNotEmpty) {
- // 4. Archive
- final appDocDir = await getApplicationDocumentsDirectory();
- final fileName = p.basename(photo.path);
- final persistentPath = p.join(appDocDir.path, 'palm_live_${DateTime.now().millisecondsSinceEpoch}_$fileName');
- await File(photo.path).copy(persistentPath);
- final best = detections.first;
- final record = PalmRecord(
- imagePath: persistentPath,
- ripenessClass: best.className,
- confidence: best.confidence,
- timestamp: DateTime.now(),
- x1: best.normalizedBox.left,
- y1: best.normalizedBox.top,
- x2: best.normalizedBox.right,
- y2: best.normalizedBox.bottom,
- detections: detections.map((d) => {
- 'className': d.className,
- 'classIndex': d.classIndex,
- 'confidence': d.confidence,
- 'x1': d.normalizedBox.left,
- 'y1': d.normalizedBox.top,
- 'x2': d.normalizedBox.right,
- 'y2': d.normalizedBox.bottom,
- }).toList(),
- );
- await _dbHelper.insertRecord(record);
- // 5. Show result and resume camera
- if (mounted) {
- await _showResultSheet(record);
- _startCooldown();
- }
- } else {
- if (mounted) {
- ScaffoldMessenger.of(context).showSnackBar(
- const SnackBar(content: Text("No palm bunches detected in final snap."))
- );
- _startCooldown();
- }
- }
- } catch (e) {
- print("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(PalmRecord record) async {
- Color statusColor = const Color(0xFFFF9800); // Default orange
- if (record.ripenessClass == 'Empty_Bunch' || record.ripenessClass == 'Abnormal') {
- statusColor = const Color(0xFFF44336);
- } else if (record.ripenessClass == 'Ripe' || record.ripenessClass == 'Overripe') {
- statusColor = const Color(0xFF4CAF50);
- }
- 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),
- child: Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- Icon(Icons.check_circle, color: statusColor, size: 64),
- const SizedBox(height: 16),
- Text(record.ripenessClass, style: const TextStyle(fontSize: 28, fontWeight: FontWeight.bold)),
- Text("Confidence: ${(record.confidence * 100).toStringAsFixed(1)}%", style: const TextStyle(color: Colors.grey)),
- const SizedBox(height: 24),
- if (record.ripenessClass == 'Empty_Bunch' || record.ripenessClass == 'Abnormal')
- Container(
- padding: const EdgeInsets.all(12),
- decoration: BoxDecoration(color: Colors.red.shade50, borderRadius: BorderRadius.circular(8)),
- child: const Text("HEALTH ALERT: Abnormal detected!", style: TextStyle(color: Colors.red, fontWeight: FontWeight.bold)),
- ),
- const SizedBox(height: 24),
- SizedBox(
- width: double.infinity,
- child: ElevatedButton(
- onPressed: () => Navigator.pop(context),
- child: const Text("Done"),
- ),
- ),
- ],
- ),
- ),
- );
- }
- @override
- Widget build(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
- Center(
- child: CameraPreview(_controller!),
- ),
-
- // Bounding Box Overlays
- if (_detections != null && _state != DetectionState.capturing && _state != DetectionState.cooldown)
- Positioned.fill(
- child: LayoutBuilder(
- builder: (context, constraints) {
- return Stack(
- children: _detections!
- .map((d) => _buildOverlayBox(d, constraints))
- .toList(),
- );
- },
- ),
- ),
- // 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 ? "COOLDOWN" :
- isLockedVisual ? "LOCKING" : "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: const Center(
- child: Text(
- "Resuming scan...",
- style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold),
- ),
- ),
- ),
- ),
- // Bottom Hint
- if (_state == DetectionState.searching)
- const Positioned(
- bottom: 40,
- left: 0,
- right: 0,
- child: Center(
- child: Text(
- "Hold steady to lock target",
- style: TextStyle(color: Colors.white, fontWeight: FontWeight.w500),
- ),
- ),
- ),
- ],
- ),
- );
- }
- Widget _buildOverlayBox(DetectionResult detection, BoxConstraints constraints) {
- final rect = detection.normalizedBox;
- // Show green only if the system is overall "Locked" and this detection is high confidence
- final color = ((_state == DetectionState.locking || _state == DetectionState.capturing) && detection.confidence > _lockThreshold) ? Colors.green : Colors.yellow;
- return Positioned(
- left: rect.left * constraints.maxWidth,
- top: rect.top * constraints.maxHeight,
- width: rect.width * constraints.maxWidth,
- height: rect.height * constraints.maxHeight,
- child: Container(
- decoration: BoxDecoration(
- border: Border.all(color: color, width: 2),
- borderRadius: BorderRadius.circular(4),
- ),
- child: Align(
- alignment: Alignment.topLeft,
- child: Container(
- padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
- color: color,
- child: Text(
- "${(detection.confidence * 100).toStringAsFixed(0)}%",
- style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold),
- ),
- ),
- ),
- ),
- );
- }
- @override
- void dispose() {
- _lockTimer?.cancel();
- _cooldownTimer?.cancel();
- _controller?.dispose();
- _tfliteService.dispose();
- super.dispose();
- }
- }
|