live_analysis_screen.dart 15 KB

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