live_analysis_screen.dart 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500
  1. import 'dart:io';
  2. import 'dart:ui';
  3. import 'dart:async';
  4. import 'package:flutter/material.dart';
  5. import 'package:camera/camera.dart';
  6. import 'package:permission_handler/permission_handler.dart';
  7. import 'package:path_provider/path_provider.dart';
  8. import 'package:path/path.dart' as p;
  9. import '../services/tflite_service.dart';
  10. import '../services/database_helper.dart';
  11. import '../models/palm_record.dart';
  12. enum DetectionState { searching, locking, capturing, cooldown }
  13. class LiveAnalysisScreen extends StatefulWidget {
  14. const LiveAnalysisScreen({super.key});
  15. @override
  16. State<LiveAnalysisScreen> createState() => _LiveAnalysisScreenState();
  17. }
  18. class _LiveAnalysisScreenState extends State<LiveAnalysisScreen> {
  19. CameraController? _controller;
  20. final TfliteService _tfliteService = TfliteService();
  21. final DatabaseHelper _dbHelper = DatabaseHelper();
  22. bool _isInitialized = false;
  23. bool _isProcessing = false;
  24. int _frameCount = 0;
  25. List<DetectionResult>? _detections;
  26. // Detection Lock Logic
  27. DetectionState _state = DetectionState.searching;
  28. static const double _lockThreshold = 0.60;
  29. static const int _frameThrottle = 2; // Check frames more frequently
  30. final List<bool> _detectionHistory = List.filled(20, false, growable: true); // 20 frames buffer
  31. static const int _requiredHits = 4; // Threshold for momentum ticks
  32. int _currentHits = 0; // Track hits for the timer
  33. Timer? _lockTimer;
  34. Timer? _cooldownTimer;
  35. double _lockProgress = 0.0;
  36. bool _showFlash = false;
  37. @override
  38. void initState() {
  39. super.initState();
  40. _initializeCamera();
  41. }
  42. Future<void> _initializeCamera() async {
  43. final status = await Permission.camera.request();
  44. if (status.isDenied) return;
  45. final cameras = await availableCameras();
  46. if (cameras.isEmpty) return;
  47. _controller = CameraController(
  48. cameras[0],
  49. ResolutionPreset.low, // Downgraded resolution for performance
  50. enableAudio: false,
  51. imageFormatGroup: Platform.isAndroid ? ImageFormatGroup.yuv420 : ImageFormatGroup.bgra8888,
  52. );
  53. try {
  54. await _controller!.initialize();
  55. await _tfliteService.initModel();
  56. _controller!.startImageStream(_handleImageStream);
  57. if (mounted) {
  58. setState(() {
  59. _isInitialized = true;
  60. });
  61. }
  62. } catch (e) {
  63. print("Camera init error: $e");
  64. }
  65. }
  66. void _handleImageStream(CameraImage image) {
  67. if (_isProcessing || _state == DetectionState.capturing || _state == DetectionState.cooldown) return;
  68. _frameCount++;
  69. if (_frameCount % _frameThrottle != 0) return;
  70. _processStreamFrame(image);
  71. }
  72. Future<void> _processStreamFrame(CameraImage image) async {
  73. setState(() => _isProcessing = true);
  74. try {
  75. final detections = await _tfliteService.runInferenceOnStream(image);
  76. bool currentFrameHasFruit = false;
  77. if (detections.isNotEmpty) {
  78. currentFrameHasFruit = detections.any((d) => d.confidence > _lockThreshold);
  79. }
  80. // Update Sliding Window Buffer
  81. _detectionHistory.removeAt(0);
  82. _detectionHistory.add(currentFrameHasFruit);
  83. _currentHits = _detectionHistory.where((h) => h).length;
  84. if (!mounted) return;
  85. setState(() {
  86. _detections = detections;
  87. });
  88. if (_state == DetectionState.searching) {
  89. if (_currentHits >= _requiredHits) {
  90. setState(() {
  91. _state = DetectionState.locking;
  92. _lockProgress = 0.0;
  93. });
  94. _startLockTimer();
  95. }
  96. }
  97. // Removed the old strict cancel logic.
  98. // _startLockTimer now safely handles momentum drain.
  99. } catch (e) {
  100. print("Stream processing error: $e");
  101. } finally {
  102. if (mounted) {
  103. setState(() => _isProcessing = false);
  104. }
  105. }
  106. }
  107. void _startLockTimer() {
  108. _lockTimer?.cancel();
  109. const duration = Duration(milliseconds: 100);
  110. int momentumTicks = 0;
  111. _lockTimer = Timer.periodic(duration, (timer) {
  112. if (!mounted) {
  113. timer.cancel();
  114. return;
  115. }
  116. // Momentum logic: add or subtract
  117. if (_currentHits >= _requiredHits) {
  118. momentumTicks++;
  119. } else {
  120. momentumTicks--;
  121. }
  122. if (momentumTicks < 0) momentumTicks = 0;
  123. setState(() {
  124. _lockProgress = (momentumTicks / 3.0).clamp(0.0, 1.0); // 3 ticks target
  125. });
  126. if (momentumTicks >= 3) {
  127. timer.cancel();
  128. if (_state == DetectionState.locking) {
  129. _triggerCapture();
  130. }
  131. } else if (momentumTicks <= 0 && _state == DetectionState.locking) {
  132. // Complete momentum loss -> Cancel lock
  133. timer.cancel();
  134. setState(() {
  135. _state = DetectionState.searching;
  136. _lockProgress = 0.0;
  137. });
  138. }
  139. });
  140. }
  141. void _cancelLockTimer() {
  142. _lockTimer?.cancel();
  143. _lockTimer = null;
  144. }
  145. Future<void> _triggerCapture() async {
  146. setState(() {
  147. _state = DetectionState.capturing;
  148. _lockProgress = 1.0;
  149. _showFlash = true;
  150. });
  151. // Quick 200ms white flash without blocking
  152. Future.delayed(const Duration(milliseconds: 200), () {
  153. if (mounted) setState(() => _showFlash = false);
  154. });
  155. await _captureAndAnalyze();
  156. }
  157. Future<void> _captureAndAnalyze() async {
  158. if (_controller == null || !_controller!.value.isInitialized) return;
  159. // 1. Stop stream to avoid resource conflict
  160. await _controller!.stopImageStream();
  161. if (!mounted) return;
  162. try {
  163. // 2. Take high-res picture
  164. final XFile photo = await _controller!.takePicture();
  165. // 3. Run final inference on high-res
  166. final detections = await _tfliteService.runInference(photo.path);
  167. if (detections.isNotEmpty) {
  168. // 4. Archive
  169. final appDocDir = await getApplicationDocumentsDirectory();
  170. final fileName = p.basename(photo.path);
  171. final persistentPath = p.join(appDocDir.path, 'palm_live_${DateTime.now().millisecondsSinceEpoch}_$fileName');
  172. await File(photo.path).copy(persistentPath);
  173. final best = detections.first;
  174. final record = PalmRecord(
  175. imagePath: persistentPath,
  176. ripenessClass: best.className,
  177. confidence: best.confidence,
  178. timestamp: DateTime.now(),
  179. x1: best.normalizedBox.left,
  180. y1: best.normalizedBox.top,
  181. x2: best.normalizedBox.right,
  182. y2: best.normalizedBox.bottom,
  183. detections: detections.map((d) => {
  184. 'className': d.className,
  185. 'classIndex': d.classIndex,
  186. 'confidence': d.confidence,
  187. 'x1': d.normalizedBox.left,
  188. 'y1': d.normalizedBox.top,
  189. 'x2': d.normalizedBox.right,
  190. 'y2': d.normalizedBox.bottom,
  191. }).toList(),
  192. );
  193. await _dbHelper.insertRecord(record);
  194. // 5. Show result and resume camera
  195. if (mounted) {
  196. await _showResultSheet(record);
  197. _startCooldown();
  198. }
  199. } else {
  200. if (mounted) {
  201. ScaffoldMessenger.of(context).showSnackBar(
  202. const SnackBar(content: Text("No palm bunches detected in final snap."))
  203. );
  204. _startCooldown();
  205. }
  206. }
  207. } catch (e) {
  208. print("Capture error: $e");
  209. if (mounted) _startCooldown();
  210. }
  211. }
  212. void _startCooldown() {
  213. if (!mounted) return;
  214. setState(() {
  215. _state = DetectionState.cooldown;
  216. _detections = null; // Clear boxes
  217. });
  218. // Clear detection history to ignore old hits
  219. _detectionHistory.fillRange(0, _detectionHistory.length, false);
  220. _cooldownTimer?.cancel();
  221. _cooldownTimer = Timer(const Duration(seconds: 3), () {
  222. if (!mounted) return;
  223. setState(() {
  224. _state = DetectionState.searching;
  225. });
  226. _controller?.startImageStream(_handleImageStream);
  227. });
  228. }
  229. Future<void> _showResultSheet(PalmRecord record) async {
  230. Color statusColor = const Color(0xFFFF9800); // Default orange
  231. if (record.ripenessClass == 'Empty_Bunch' || record.ripenessClass == 'Abnormal') {
  232. statusColor = const Color(0xFFF44336);
  233. } else if (record.ripenessClass == 'Ripe' || record.ripenessClass == 'Overripe') {
  234. statusColor = const Color(0xFF4CAF50);
  235. }
  236. await showModalBottomSheet(
  237. context: context,
  238. isScrollControlled: true,
  239. isDismissible: false,
  240. enableDrag: false,
  241. shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20))),
  242. builder: (context) => Container(
  243. padding: const EdgeInsets.all(24),
  244. child: Column(
  245. mainAxisSize: MainAxisSize.min,
  246. children: [
  247. Icon(Icons.check_circle, color: statusColor, size: 64),
  248. const SizedBox(height: 16),
  249. Text(record.ripenessClass, style: const TextStyle(fontSize: 28, fontWeight: FontWeight.bold)),
  250. Text("Confidence: ${(record.confidence * 100).toStringAsFixed(1)}%", style: const TextStyle(color: Colors.grey)),
  251. const SizedBox(height: 24),
  252. if (record.ripenessClass == 'Empty_Bunch' || record.ripenessClass == 'Abnormal')
  253. Container(
  254. padding: const EdgeInsets.all(12),
  255. decoration: BoxDecoration(color: Colors.red.shade50, borderRadius: BorderRadius.circular(8)),
  256. child: const Text("HEALTH ALERT: Abnormal detected!", style: TextStyle(color: Colors.red, fontWeight: FontWeight.bold)),
  257. ),
  258. const SizedBox(height: 24),
  259. SizedBox(
  260. width: double.infinity,
  261. child: ElevatedButton(
  262. onPressed: () => Navigator.pop(context),
  263. child: const Text("Done"),
  264. ),
  265. ),
  266. ],
  267. ),
  268. ),
  269. );
  270. }
  271. @override
  272. Widget build(BuildContext context) {
  273. if (!_isInitialized || _controller == null) {
  274. return const Scaffold(body: Center(child: CircularProgressIndicator()));
  275. }
  276. final isLockedVisual = _state == DetectionState.locking || _state == DetectionState.capturing;
  277. return Scaffold(
  278. backgroundColor: Colors.black,
  279. body: Stack(
  280. children: [
  281. // Camera Preview
  282. Center(
  283. child: CameraPreview(_controller!),
  284. ),
  285. // Bounding Box Overlays
  286. if (_detections != null && _state != DetectionState.capturing && _state != DetectionState.cooldown)
  287. Positioned.fill(
  288. child: LayoutBuilder(
  289. builder: (context, constraints) {
  290. return Stack(
  291. children: _detections!
  292. .map((d) => _buildOverlayBox(d, constraints))
  293. .toList(),
  294. );
  295. },
  296. ),
  297. ),
  298. // Top Info Bar
  299. Positioned(
  300. top: 40,
  301. left: 20,
  302. right: 20,
  303. child: Container(
  304. padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
  305. decoration: BoxDecoration(
  306. color: Colors.black54,
  307. borderRadius: BorderRadius.circular(20),
  308. ),
  309. child: Row(
  310. children: [
  311. Icon(
  312. _state == DetectionState.cooldown ? Icons.pause_circle_filled :
  313. isLockedVisual ? Icons.lock : Icons.center_focus_weak,
  314. color: _state == DetectionState.cooldown ? Colors.blue :
  315. isLockedVisual ? Colors.green : Colors.yellow,
  316. ),
  317. const SizedBox(width: 8),
  318. Text(
  319. _state == DetectionState.cooldown ? "COOLDOWN" :
  320. isLockedVisual ? "LOCKING" : "SEARCHING...",
  321. style: TextStyle(
  322. color: _state == DetectionState.cooldown ? Colors.blue :
  323. isLockedVisual ? Colors.green : Colors.yellow,
  324. fontWeight: FontWeight.bold,
  325. ),
  326. ),
  327. const Spacer(),
  328. IconButton(
  329. icon: const Icon(Icons.close, color: Colors.white),
  330. onPressed: () => Navigator.pop(context),
  331. ),
  332. ],
  333. ),
  334. ),
  335. ),
  336. // Progress Overlay for Locking
  337. if (_state == DetectionState.locking)
  338. Positioned.fill(
  339. child: Center(
  340. child: SizedBox(
  341. width: 120,
  342. height: 120,
  343. child: TweenAnimationBuilder<double>(
  344. tween: Tween<double>(begin: 0.0, end: _lockProgress),
  345. duration: const Duration(milliseconds: 100),
  346. builder: (context, value, _) => CircularProgressIndicator(
  347. value: value,
  348. strokeWidth: 8,
  349. color: Colors.greenAccent,
  350. backgroundColor: Colors.white24,
  351. ),
  352. ),
  353. ),
  354. ),
  355. ),
  356. // White flash overlay
  357. Positioned.fill(
  358. child: IgnorePointer(
  359. child: AnimatedOpacity(
  360. opacity: _showFlash ? 1.0 : 0.0,
  361. duration: const Duration(milliseconds: 200),
  362. child: Container(color: Colors.white),
  363. ),
  364. ),
  365. ),
  366. if (_state == DetectionState.capturing && !_showFlash)
  367. Positioned.fill(
  368. child: Container(
  369. color: Colors.black45,
  370. child: const Center(
  371. child: CircularProgressIndicator(color: Colors.white),
  372. ),
  373. ),
  374. ),
  375. if (_state == DetectionState.cooldown)
  376. Positioned.fill(
  377. child: Container(
  378. color: Colors.black45,
  379. child: const Center(
  380. child: Text(
  381. "Resuming scan...",
  382. style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold),
  383. ),
  384. ),
  385. ),
  386. ),
  387. // Bottom Hint
  388. if (_state == DetectionState.searching)
  389. const Positioned(
  390. bottom: 40,
  391. left: 0,
  392. right: 0,
  393. child: Center(
  394. child: Text(
  395. "Hold steady to lock target",
  396. style: TextStyle(color: Colors.white, fontWeight: FontWeight.w500),
  397. ),
  398. ),
  399. ),
  400. ],
  401. ),
  402. );
  403. }
  404. Widget _buildOverlayBox(DetectionResult detection, BoxConstraints constraints) {
  405. final rect = detection.normalizedBox;
  406. // Show green only if the system is overall "Locked" and this detection is high confidence
  407. final color = ((_state == DetectionState.locking || _state == DetectionState.capturing) && detection.confidence > _lockThreshold) ? Colors.green : Colors.yellow;
  408. return Positioned(
  409. left: rect.left * constraints.maxWidth,
  410. top: rect.top * constraints.maxHeight,
  411. width: rect.width * constraints.maxWidth,
  412. height: rect.height * constraints.maxHeight,
  413. child: Container(
  414. decoration: BoxDecoration(
  415. border: Border.all(color: color, width: 2),
  416. borderRadius: BorderRadius.circular(4),
  417. ),
  418. child: Align(
  419. alignment: Alignment.topLeft,
  420. child: Container(
  421. padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
  422. color: color,
  423. child: Text(
  424. "${(detection.confidence * 100).toStringAsFixed(0)}%",
  425. style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold),
  426. ),
  427. ),
  428. ),
  429. ),
  430. );
  431. }
  432. @override
  433. void dispose() {
  434. _lockTimer?.cancel();
  435. _cooldownTimer?.cancel();
  436. _controller?.dispose();
  437. _tfliteService.dispose();
  438. super.dispose();
  439. }
  440. }