live_analysis_screen.dart 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385
  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. final List<bool> _detectionHistory = List.filled(10, false, growable: true);
  29. static const int _requiredHits = 4; // 4 out of 10 for a lock
  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, // Restoring to a valid preset
  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. // Update Sliding Window Buffer
  73. _detectionHistory.removeAt(0);
  74. _detectionHistory.add(currentFrameHasFruit);
  75. final hits = _detectionHistory.where((h) => h).length;
  76. if (mounted) {
  77. setState(() {
  78. _detections = detections;
  79. _isLocked = hits >= _requiredHits;
  80. });
  81. }
  82. } catch (e) {
  83. print("Stream processing error: $e");
  84. } finally {
  85. _isProcessing = false;
  86. }
  87. }
  88. Future<void> _captureAndAnalyze() async {
  89. if (_controller == null || !_controller!.value.isInitialized) return;
  90. // 1. Stop stream to avoid resource conflict
  91. await _controller!.stopImageStream();
  92. // Show loading dialog
  93. if (!mounted) return;
  94. _showLoadingDialog();
  95. try {
  96. // 2. Take high-res picture
  97. final XFile photo = await _controller!.takePicture();
  98. // 3. Run final inference on high-res
  99. final detections = await _tfliteService.runInference(photo.path);
  100. if (detections.isNotEmpty) {
  101. // 4. Archive
  102. final appDocDir = await getApplicationDocumentsDirectory();
  103. final fileName = p.basename(photo.path);
  104. final persistentPath = p.join(appDocDir.path, 'palm_live_${DateTime.now().millisecondsSinceEpoch}_$fileName');
  105. await File(photo.path).copy(persistentPath);
  106. final best = detections.first;
  107. final record = PalmRecord(
  108. imagePath: persistentPath,
  109. ripenessClass: best.className,
  110. confidence: best.confidence,
  111. timestamp: DateTime.now(),
  112. x1: best.normalizedBox.left,
  113. y1: best.normalizedBox.top,
  114. x2: best.normalizedBox.right,
  115. y2: best.normalizedBox.bottom,
  116. detections: detections.map((d) => {
  117. 'className': d.className,
  118. 'classIndex': d.classIndex,
  119. 'confidence': d.confidence,
  120. 'x1': d.normalizedBox.left,
  121. 'y1': d.normalizedBox.top,
  122. 'x2': d.normalizedBox.right,
  123. 'y2': d.normalizedBox.bottom,
  124. }).toList(),
  125. );
  126. await _dbHelper.insertRecord(record);
  127. // 5. Show result and resume camera
  128. if (mounted) {
  129. Navigator.of(context).pop(); // Close loading
  130. _showResultSheet(record);
  131. }
  132. } else {
  133. if (mounted) {
  134. Navigator.of(context).pop();
  135. ScaffoldMessenger.of(context).showSnackBar(
  136. const SnackBar(content: Text("No palm bunches detected in final snap."))
  137. );
  138. }
  139. }
  140. } catch (e) {
  141. if (mounted) Navigator.of(context).pop();
  142. print("Capture error: $e");
  143. } finally {
  144. // Restart stream
  145. _controller!.startImageStream((image) {
  146. if (_isProcessing) return;
  147. _frameCount++;
  148. if (_frameCount % _frameThrottle != 0) return;
  149. _processStreamFrame(image);
  150. });
  151. }
  152. }
  153. void _showLoadingDialog() {
  154. showDialog(
  155. context: context,
  156. barrierDismissible: false,
  157. builder: (context) => const Center(
  158. child: Column(
  159. mainAxisSize: MainAxisSize.min,
  160. children: [
  161. CircularProgressIndicator(color: Colors.white),
  162. SizedBox(height: 16),
  163. Text("Final Grading...", style: TextStyle(color: Colors.white)),
  164. ],
  165. ),
  166. ),
  167. );
  168. }
  169. void _showResultSheet(PalmRecord record) {
  170. // Determine color based on ripeness class
  171. Color statusColor = const Color(0xFFFF9800); // Default orange
  172. if (record.ripenessClass == 'Empty_Bunch' || record.ripenessClass == 'Abnormal') {
  173. statusColor = const Color(0xFFF44336);
  174. } else if (record.ripenessClass == 'Ripe' || record.ripenessClass == 'Overripe') {
  175. statusColor = const Color(0xFF4CAF50);
  176. }
  177. showModalBottomSheet(
  178. context: context,
  179. isScrollControlled: true,
  180. shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20))),
  181. builder: (context) => Container(
  182. padding: const EdgeInsets.all(24),
  183. child: Column(
  184. mainAxisSize: MainAxisSize.min,
  185. children: [
  186. Icon(Icons.check_circle, color: statusColor, size: 64),
  187. const SizedBox(height: 16),
  188. Text(record.ripenessClass, style: const TextStyle(fontSize: 28, fontWeight: FontWeight.bold)),
  189. Text("Confidence: ${(record.confidence * 100).toStringAsFixed(1)}%", style: const TextStyle(color: Colors.grey)),
  190. const SizedBox(height: 24),
  191. if (record.ripenessClass == 'Empty_Bunch' || record.ripenessClass == 'Abnormal')
  192. Container(
  193. padding: const EdgeInsets.all(12),
  194. decoration: BoxDecoration(color: Colors.red.shade50, borderRadius: BorderRadius.circular(8)),
  195. child: const Text("HEALTH ALERT: Abnormal detected!", style: TextStyle(color: Colors.red, fontWeight: FontWeight.bold)),
  196. ),
  197. const SizedBox(height: 24),
  198. SizedBox(
  199. width: double.infinity,
  200. child: ElevatedButton(
  201. onPressed: () => Navigator.pop(context),
  202. child: const Text("Done"),
  203. ),
  204. ),
  205. ],
  206. ),
  207. ),
  208. );
  209. }
  210. @override
  211. Widget build(BuildContext context) {
  212. if (!_isInitialized || _controller == null) {
  213. return const Scaffold(body: Center(child: CircularProgressIndicator()));
  214. }
  215. return Scaffold(
  216. backgroundColor: Colors.black,
  217. body: Stack(
  218. children: [
  219. // Camera Preview
  220. Center(
  221. child: CameraPreview(_controller!),
  222. ),
  223. // Bounding Box Overlays
  224. if (_detections != null)
  225. Positioned.fill(
  226. child: LayoutBuilder(
  227. builder: (context, constraints) {
  228. return Stack(
  229. children: _detections!
  230. .map((d) => _buildOverlayBox(d, constraints))
  231. .toList(),
  232. );
  233. },
  234. ),
  235. ),
  236. // Top Info Bar
  237. Positioned(
  238. top: 40,
  239. left: 20,
  240. right: 20,
  241. child: Container(
  242. padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
  243. decoration: BoxDecoration(
  244. color: Colors.black54,
  245. borderRadius: BorderRadius.circular(20),
  246. ),
  247. child: Row(
  248. children: [
  249. Icon(
  250. _isLocked ? Icons.lock : Icons.center_focus_weak,
  251. color: _isLocked ? Colors.green : Colors.yellow,
  252. ),
  253. const SizedBox(width: 8),
  254. Text(
  255. _isLocked ? "LOCKED" : "TARGETING...",
  256. style: TextStyle(
  257. color: _isLocked ? Colors.green : Colors.yellow,
  258. fontWeight: FontWeight.bold,
  259. ),
  260. ),
  261. const Spacer(),
  262. IconButton(
  263. icon: const Icon(Icons.close, color: Colors.white),
  264. onPressed: () => Navigator.pop(context),
  265. ),
  266. ],
  267. ),
  268. ),
  269. ),
  270. // Bottom Controls
  271. Positioned(
  272. bottom: 40,
  273. left: 0,
  274. right: 0,
  275. child: Center(
  276. child: GestureDetector(
  277. onTap: _isLocked ? _captureAndAnalyze : null,
  278. child: Container(
  279. width: 80,
  280. height: 80,
  281. decoration: BoxDecoration(
  282. shape: BoxShape.circle,
  283. border: Border.all(color: Colors.white, width: 4),
  284. color: _isLocked ? Colors.green.withOpacity(0.8) : Colors.white24,
  285. ),
  286. child: Icon(
  287. _isLocked ? Icons.camera_alt : Icons.hourglass_empty,
  288. color: Colors.white,
  289. size: 32,
  290. ),
  291. ),
  292. ),
  293. ),
  294. ),
  295. if (!_isLocked)
  296. const Positioned(
  297. bottom: 130,
  298. left: 0,
  299. right: 0,
  300. child: Center(
  301. child: Text(
  302. "Hold steady to lock target",
  303. style: TextStyle(color: Colors.white, fontWeight: FontWeight.w500),
  304. ),
  305. ),
  306. ),
  307. ],
  308. ),
  309. );
  310. }
  311. Widget _buildOverlayBox(DetectionResult detection, BoxConstraints constraints) {
  312. final rect = detection.normalizedBox;
  313. // Show green only if the system is overall "Locked" and this detection is high confidence
  314. final color = (_isLocked && detection.confidence > _lockThreshold) ? Colors.green : Colors.yellow;
  315. return Positioned(
  316. left: rect.left * constraints.maxWidth,
  317. top: rect.top * constraints.maxHeight,
  318. width: rect.width * constraints.maxWidth,
  319. height: rect.height * constraints.maxHeight,
  320. child: Container(
  321. decoration: BoxDecoration(
  322. border: Border.all(color: color, width: 2),
  323. borderRadius: BorderRadius.circular(4),
  324. ),
  325. child: Align(
  326. alignment: Alignment.topLeft,
  327. child: Container(
  328. padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
  329. color: color,
  330. child: Text(
  331. "${(detection.confidence * 100).toStringAsFixed(0)}%",
  332. style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold),
  333. ),
  334. ),
  335. ),
  336. ),
  337. );
  338. }
  339. @override
  340. void dispose() {
  341. _controller?.dispose();
  342. _tfliteService.dispose();
  343. super.dispose();
  344. }
  345. }