live_analysis_screen.dart 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386
  1. import 'dart:io';
  2. import 'dart:ui';
  3. import 'package:flutter/material.dart';
  4. import 'package:camera/camera.dart';
  5. import 'package:permission_handler/permission_handler.dart';
  6. import 'package:path_provider/path_provider.dart';
  7. import 'package:path/path.dart' as p;
  8. import '../services/tflite_service.dart';
  9. import '../services/database_helper.dart';
  10. import '../models/palm_record.dart';
  11. class LiveAnalysisScreen extends StatefulWidget {
  12. const LiveAnalysisScreen({super.key});
  13. @override
  14. State<LiveAnalysisScreen> createState() => _LiveAnalysisScreenState();
  15. }
  16. class _LiveAnalysisScreenState extends State<LiveAnalysisScreen> {
  17. CameraController? _controller;
  18. final TfliteService _tfliteService = TfliteService();
  19. final DatabaseHelper _dbHelper = DatabaseHelper();
  20. bool _isInitialized = false;
  21. bool _isProcessing = false;
  22. int _frameCount = 0;
  23. List<DetectionResult>? _detections;
  24. // Detection Lock Logic
  25. bool _isLocked = false;
  26. static const double _lockThreshold = 0.60;
  27. static const int _frameThrottle = 2; // Check frames more frequently
  28. int _consecutiveDetections = 0;
  29. static const int _requiredConsecutive = 3; // Number of frames to hold
  30. @override
  31. void initState() {
  32. super.initState();
  33. _initializeCamera();
  34. }
  35. Future<void> _initializeCamera() async {
  36. final status = await Permission.camera.request();
  37. if (status.isDenied) return;
  38. final cameras = await availableCameras();
  39. if (cameras.isEmpty) return;
  40. _controller = CameraController(
  41. cameras[0],
  42. ResolutionPreset.medium,
  43. enableAudio: false,
  44. imageFormatGroup: Platform.isAndroid ? ImageFormatGroup.yuv420 : ImageFormatGroup.bgra8888,
  45. );
  46. try {
  47. await _controller!.initialize();
  48. await _tfliteService.initModel();
  49. _controller!.startImageStream((image) {
  50. if (_isProcessing) return;
  51. _frameCount++;
  52. if (_frameCount % _frameThrottle != 0) return;
  53. _processStreamFrame(image);
  54. });
  55. if (mounted) {
  56. setState(() {
  57. _isInitialized = true;
  58. });
  59. }
  60. } catch (e) {
  61. print("Camera init error: $e");
  62. }
  63. }
  64. Future<void> _processStreamFrame(CameraImage image) async {
  65. setState(() => _isProcessing = true);
  66. try {
  67. final detections = await _tfliteService.runInferenceOnStream(image);
  68. bool currentFrameHasFruit = false;
  69. if (detections.isNotEmpty) {
  70. currentFrameHasFruit = detections.any((d) => d.confidence > _lockThreshold);
  71. }
  72. if (currentFrameHasFruit) {
  73. _consecutiveDetections++;
  74. } else {
  75. _consecutiveDetections--; // Just drop by one, don't kill the whole lock
  76. }
  77. _consecutiveDetections = _consecutiveDetections.clamp(0, _requiredConsecutive);
  78. if (mounted) {
  79. setState(() {
  80. _detections = detections;
  81. _isLocked = _consecutiveDetections >= _requiredConsecutive;
  82. });
  83. }
  84. } catch (e) {
  85. print("Stream processing error: $e");
  86. } finally {
  87. _isProcessing = false;
  88. }
  89. }
  90. Future<void> _captureAndAnalyze() async {
  91. if (_controller == null || !_controller!.value.isInitialized) return;
  92. // 1. Stop stream to avoid resource conflict
  93. await _controller!.stopImageStream();
  94. // Show loading dialog
  95. if (!mounted) return;
  96. _showLoadingDialog();
  97. try {
  98. // 2. Take high-res picture
  99. final XFile photo = await _controller!.takePicture();
  100. // 3. Run final inference on high-res
  101. final detections = await _tfliteService.runInference(photo.path);
  102. if (detections.isNotEmpty) {
  103. // 4. Archive
  104. final appDocDir = await getApplicationDocumentsDirectory();
  105. final fileName = p.basename(photo.path);
  106. final persistentPath = p.join(appDocDir.path, 'palm_live_${DateTime.now().millisecondsSinceEpoch}_$fileName');
  107. await File(photo.path).copy(persistentPath);
  108. final best = detections.first;
  109. final record = PalmRecord(
  110. imagePath: persistentPath,
  111. ripenessClass: best.className,
  112. confidence: best.confidence,
  113. timestamp: DateTime.now(),
  114. x1: best.normalizedBox.left,
  115. y1: best.normalizedBox.top,
  116. x2: best.normalizedBox.right,
  117. y2: best.normalizedBox.bottom,
  118. detections: detections.map((d) => {
  119. 'className': d.className,
  120. 'classIndex': d.classIndex,
  121. 'confidence': d.confidence,
  122. 'x1': d.normalizedBox.left,
  123. 'y1': d.normalizedBox.top,
  124. 'x2': d.normalizedBox.right,
  125. 'y2': d.normalizedBox.bottom,
  126. }).toList(),
  127. );
  128. await _dbHelper.insertRecord(record);
  129. // 5. Show result and resume camera
  130. if (mounted) {
  131. Navigator.of(context).pop(); // Close loading
  132. _showResultSheet(record);
  133. }
  134. } else {
  135. if (mounted) {
  136. Navigator.of(context).pop();
  137. ScaffoldMessenger.of(context).showSnackBar(
  138. const SnackBar(content: Text("No palm bunches detected in final snap."))
  139. );
  140. }
  141. }
  142. } catch (e) {
  143. if (mounted) Navigator.of(context).pop();
  144. print("Capture error: $e");
  145. } finally {
  146. // Restart stream
  147. _controller!.startImageStream((image) {
  148. if (_isProcessing) return;
  149. _frameCount++;
  150. if (_frameCount % _frameThrottle != 0) return;
  151. _processStreamFrame(image);
  152. });
  153. }
  154. }
  155. void _showLoadingDialog() {
  156. showDialog(
  157. context: context,
  158. barrierDismissible: false,
  159. builder: (context) => const Center(
  160. child: Column(
  161. mainAxisSize: MainAxisSize.min,
  162. children: [
  163. CircularProgressIndicator(color: Colors.white),
  164. SizedBox(height: 16),
  165. Text("Final Grading...", style: TextStyle(color: Colors.white)),
  166. ],
  167. ),
  168. ),
  169. );
  170. }
  171. void _showResultSheet(PalmRecord record) {
  172. // Determine color based on ripeness class
  173. Color statusColor = const Color(0xFFFF9800); // Default orange
  174. if (record.ripenessClass == 'Empty_Bunch' || record.ripenessClass == 'Abnormal') {
  175. statusColor = const Color(0xFFF44336);
  176. } else if (record.ripenessClass == 'Ripe' || record.ripenessClass == 'Overripe') {
  177. statusColor = const Color(0xFF4CAF50);
  178. }
  179. showModalBottomSheet(
  180. context: context,
  181. isScrollControlled: true,
  182. shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20))),
  183. builder: (context) => Container(
  184. padding: const EdgeInsets.all(24),
  185. child: Column(
  186. mainAxisSize: MainAxisSize.min,
  187. children: [
  188. Icon(Icons.check_circle, color: statusColor, size: 64),
  189. const SizedBox(height: 16),
  190. Text(record.ripenessClass, style: const TextStyle(fontSize: 28, fontWeight: FontWeight.bold)),
  191. Text("Confidence: ${(record.confidence * 100).toStringAsFixed(1)}%", style: const TextStyle(color: Colors.grey)),
  192. const SizedBox(height: 24),
  193. if (record.ripenessClass == 'Empty_Bunch' || record.ripenessClass == 'Abnormal')
  194. Container(
  195. padding: const EdgeInsets.all(12),
  196. decoration: BoxDecoration(color: Colors.red.shade50, borderRadius: BorderRadius.circular(8)),
  197. child: const Text("HEALTH ALERT: Abnormal detected!", style: TextStyle(color: Colors.red, fontWeight: FontWeight.bold)),
  198. ),
  199. const SizedBox(height: 24),
  200. SizedBox(
  201. width: double.infinity,
  202. child: ElevatedButton(
  203. onPressed: () => Navigator.pop(context),
  204. child: const Text("Done"),
  205. ),
  206. ),
  207. ],
  208. ),
  209. ),
  210. );
  211. }
  212. @override
  213. Widget build(BuildContext context) {
  214. if (!_isInitialized || _controller == null) {
  215. return const Scaffold(body: Center(child: CircularProgressIndicator()));
  216. }
  217. return Scaffold(
  218. backgroundColor: Colors.black,
  219. body: Stack(
  220. children: [
  221. // Camera Preview
  222. Center(
  223. child: CameraPreview(_controller!),
  224. ),
  225. // Bounding Box Overlays
  226. if (_detections != null)
  227. Positioned.fill(
  228. child: LayoutBuilder(
  229. builder: (context, constraints) {
  230. return Stack(
  231. children: _detections!
  232. .map((d) => _buildOverlayBox(d, constraints))
  233. .toList(),
  234. );
  235. },
  236. ),
  237. ),
  238. // Top Info Bar
  239. Positioned(
  240. top: 40,
  241. left: 20,
  242. right: 20,
  243. child: Container(
  244. padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
  245. decoration: BoxDecoration(
  246. color: Colors.black54,
  247. borderRadius: BorderRadius.circular(20),
  248. ),
  249. child: Row(
  250. children: [
  251. Icon(
  252. _isLocked ? Icons.lock : Icons.center_focus_weak,
  253. color: _isLocked ? Colors.green : Colors.yellow,
  254. ),
  255. const SizedBox(width: 8),
  256. Text(
  257. _isLocked ? "LOCKED" : "TARGETING...",
  258. style: TextStyle(
  259. color: _isLocked ? Colors.green : Colors.yellow,
  260. fontWeight: FontWeight.bold,
  261. ),
  262. ),
  263. const Spacer(),
  264. IconButton(
  265. icon: const Icon(Icons.close, color: Colors.white),
  266. onPressed: () => Navigator.pop(context),
  267. ),
  268. ],
  269. ),
  270. ),
  271. ),
  272. // Bottom Controls
  273. Positioned(
  274. bottom: 40,
  275. left: 0,
  276. right: 0,
  277. child: Center(
  278. child: GestureDetector(
  279. onTap: _isLocked ? _captureAndAnalyze : null,
  280. child: Container(
  281. width: 80,
  282. height: 80,
  283. decoration: BoxDecoration(
  284. shape: BoxShape.circle,
  285. border: Border.all(color: Colors.white, width: 4),
  286. color: _isLocked ? Colors.green.withValues(alpha: 0.8) : Colors.white24,
  287. ),
  288. child: Icon(
  289. _isLocked ? Icons.camera_alt : Icons.hourglass_empty,
  290. color: Colors.white,
  291. size: 32,
  292. ),
  293. ),
  294. ),
  295. ),
  296. ),
  297. if (!_isLocked)
  298. const Positioned(
  299. bottom: 130,
  300. left: 0,
  301. right: 0,
  302. child: Center(
  303. child: Text(
  304. "Hold steady to lock target",
  305. style: TextStyle(color: Colors.white, fontWeight: FontWeight.w500),
  306. ),
  307. ),
  308. ),
  309. ],
  310. ),
  311. );
  312. }
  313. Widget _buildOverlayBox(DetectionResult detection, BoxConstraints constraints) {
  314. final rect = detection.normalizedBox;
  315. final color = detection.confidence > _lockThreshold ? Colors.green : Colors.yellow;
  316. return Positioned(
  317. left: rect.left * constraints.maxWidth,
  318. top: rect.top * constraints.maxHeight,
  319. width: rect.width * constraints.maxWidth,
  320. height: rect.height * constraints.maxHeight,
  321. child: Container(
  322. decoration: BoxDecoration(
  323. border: Border.all(color: color, width: 2),
  324. borderRadius: BorderRadius.circular(4),
  325. ),
  326. child: Align(
  327. alignment: Alignment.topLeft,
  328. child: Container(
  329. padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
  330. color: color,
  331. child: Text(
  332. "${(detection.confidence * 100).toStringAsFixed(0)}%",
  333. style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold),
  334. ),
  335. ),
  336. ),
  337. ),
  338. );
  339. }
  340. @override
  341. void dispose() {
  342. _controller?.dispose();
  343. _tfliteService.dispose();
  344. super.dispose();
  345. }
  346. }