live_analysis_screen.dart 12 KB

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