فهرست منبع

new snap and analyze screen. Alternative for live analysis

Dr-Swopt 1 هفته پیش
والد
کامیت
a5099bff86

+ 13 - 0
palm_oil_mobile/lib/screens/home_screen.dart

@@ -1,6 +1,7 @@
 import 'package:flutter/material.dart';
 import 'analysis_screen.dart';
 import 'live_analysis_screen.dart';
+import 'static_capture_screen.dart';
 import 'history_screen.dart';
 
 class HomeScreen extends StatelessWidget {
@@ -38,6 +39,18 @@ class HomeScreen extends StatelessWidget {
                 ),
               ),
               const SizedBox(height: 24),
+              _buildNavCard(
+                context,
+                title: 'Snap & Analyze',
+                subtitle: 'Manual high-res capture',
+                icon: Icons.camera_alt,
+                color: Colors.teal,
+                onTap: () => Navigator.push(
+                  context,
+                  MaterialPageRoute(builder: (context) => const StaticCaptureScreen()),
+                ),
+              ),
+              const SizedBox(height: 24),
               _buildNavCard(
                 context,
                 title: 'Live Inference',

+ 7 - 7
palm_oil_mobile/lib/screens/live_analysis_screen.dart

@@ -26,16 +26,15 @@ class _LiveAnalysisScreenState extends State<LiveAnalysisScreen> {
 
   bool _isInitialized = false;
   bool _isProcessing = false;
-  int _frameCount = 0;
   List<DetectionResult>? _detections;
   
   // Detection Lock Logic
   DetectionState _state = DetectionState.searching;
   static const double _lockThreshold = 0.60;
-  static const int _frameThrottle = 2; // Check frames more frequently
+  // Frame Throttle removed: Atomic Idle Lock active.
   
-  final List<bool> _detectionHistory = List.filled(20, false, growable: true); // 20 frames buffer
-  static const int _requiredHits = 4; // Threshold for momentum ticks
+  final List<bool> _detectionHistory = List.filled(15, false, growable: true); // 15 frames buffer
+  static const int _requiredHits = 5; // Target hits for momentum lock
   int _currentHits = 0;               // Track hits for the timer
 
   Timer? _lockTimer;
@@ -80,9 +79,10 @@ class _LiveAnalysisScreenState extends State<LiveAnalysisScreen> {
   }
 
   void _handleImageStream(CameraImage image) {
-    if (_isProcessing || _state == DetectionState.capturing || _state == DetectionState.cooldown) return;
-    _frameCount++;
-    if (_frameCount % _frameThrottle != 0) return;
+    // Atomic Idle Lock: Only process if the Persistent Isolate is truly idle
+    if (_isProcessing || _tfliteService.isIsolateBusy || _state == DetectionState.capturing || _state == DetectionState.cooldown) {
+      return; 
+    }
 
     _processStreamFrame(image);
   }

+ 444 - 0
palm_oil_mobile/lib/screens/static_capture_screen.dart

@@ -0,0 +1,444 @@
+import 'dart:io';
+import 'dart:ui';
+import 'package:flutter/material.dart';
+import 'package:camera/camera.dart';
+import 'package:path_provider/path_provider.dart';
+import 'package:path/path.dart' as p;
+import 'package:permission_handler/permission_handler.dart';
+import '../services/tflite_service.dart';
+import '../services/database_helper.dart';
+import '../models/palm_record.dart';
+
+class StaticCaptureScreen extends StatefulWidget {
+  const StaticCaptureScreen({super.key});
+
+  @override
+  State<StaticCaptureScreen> createState() => _StaticCaptureScreenState();
+}
+
+class _StaticCaptureScreenState extends State<StaticCaptureScreen> {
+  CameraController? _controller;
+  final TfliteService _tfliteService = TfliteService();
+  final DatabaseHelper _dbHelper = DatabaseHelper();
+
+  bool _isInitialized = false;
+  bool _isAnalyzing = false;
+  XFile? _capturedPhoto;
+  List<DetectionResult>? _detections;
+  String? _errorMessage;
+
+  @override
+  void initState() {
+    super.initState();
+    _initializeCamera();
+  }
+
+  Future<void> _initializeCamera() async {
+    final status = await Permission.camera.request();
+    if (status.isDenied) {
+      if (mounted) {
+        setState(() => _errorMessage = "Camera permission denied.");
+      }
+      return;
+    }
+
+    final cameras = await availableCameras();
+    if (cameras.isEmpty) {
+      if (mounted) {
+        setState(() => _errorMessage = "No cameras found.");
+      }
+      return;
+    }
+
+    _controller = CameraController(
+      cameras[0],
+      ResolutionPreset.high,
+      enableAudio: false,
+    );
+
+    try {
+      await _controller!.initialize();
+      await _tfliteService.initModel();
+      if (mounted) {
+        setState(() => _isInitialized = true);
+      }
+    } catch (e) {
+      if (mounted) {
+        setState(() => _errorMessage = "Camera init error: $e");
+      }
+    }
+  }
+
+  Future<void> _takeAndAnalyze() async {
+    if (_controller == null || !_controller!.value.isInitialized || _isAnalyzing) return;
+
+    setState(() {
+      _isAnalyzing = true;
+      _errorMessage = null;
+      _detections = null;
+    });
+
+    try {
+      // 1. Capture Photo
+      final XFile photo = await _controller!.takePicture();
+      if (mounted) {
+        setState(() => _capturedPhoto = photo);
+      }
+
+      // 2. Run Inference
+      final detections = await _tfliteService.runInference(photo.path);
+
+      if (detections.isNotEmpty) {
+        // 3. Persist Image
+        final appDocDir = await getApplicationDocumentsDirectory();
+        final fileName = p.basename(photo.path);
+        final persistentPath = p.join(appDocDir.path, 'palm_static_${DateTime.now().millisecondsSinceEpoch}_$fileName');
+        await File(photo.path).copy(persistentPath);
+
+        // 4. Save to Database
+        final best = detections.first;
+        final record = PalmRecord(
+          imagePath: persistentPath,
+          ripenessClass: best.className,
+          confidence: best.confidence,
+          timestamp: DateTime.now(),
+          x1: best.normalizedBox.left,
+          y1: best.normalizedBox.top,
+          x2: best.normalizedBox.right,
+          y2: best.normalizedBox.bottom,
+          detections: detections.map((d) => {
+            'className': d.className,
+            'classIndex': d.classIndex,
+            'confidence': d.confidence,
+            'x1': d.normalizedBox.left,
+            'y1': d.normalizedBox.top,
+            'x2': d.normalizedBox.right,
+            'y2': d.normalizedBox.bottom,
+          }).toList(),
+        );
+
+        await _dbHelper.insertRecord(record);
+
+        if (mounted) {
+          setState(() {
+            _detections = detections;
+            _capturedPhoto = XFile(persistentPath);
+          });
+          _showResultSheet(record);
+        }
+      } else {
+        if (mounted) {
+          setState(() => _errorMessage = "No palm bunches detected.");
+        }
+      }
+    } catch (e) {
+      if (mounted) {
+        setState(() => _errorMessage = "Error during capture/analysis: $e");
+      }
+    } finally {
+      if (mounted) {
+        setState(() => _isAnalyzing = false);
+      }
+    }
+  }
+
+  Future<void> _showResultSheet(PalmRecord record) async {
+    await showModalBottomSheet(
+      context: context,
+      isScrollControlled: true,
+      backgroundColor: Colors.transparent,
+      builder: (context) => _ResultSheet(record: record),
+    );
+    
+    // Auto-reset once dismissed
+    if (mounted) {
+      setState(() {
+        _capturedPhoto = null;
+        _detections = null;
+      });
+    }
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    if (_errorMessage != null) {
+      return Scaffold(
+        appBar: AppBar(title: const Text('Capture & Analyze')),
+        body: Center(
+          child: Column(
+            mainAxisAlignment: MainAxisAlignment.center,
+            children: [
+              Text(_errorMessage!, style: const TextStyle(color: Colors.red)),
+              const SizedBox(height: 16),
+              ElevatedButton(onPressed: _initializeCamera, child: const Text("Retry")),
+            ],
+          ),
+        ),
+      );
+    }
+
+    if (!_isInitialized || _controller == null) {
+      return const Scaffold(body: Center(child: CircularProgressIndicator()));
+    }
+
+    return Scaffold(
+      backgroundColor: Colors.black,
+      body: Stack(
+        children: [
+          // Camera Preview or Captured Image
+          Center(
+            child: _capturedPhoto != null && _detections != null
+                ? AspectRatio(
+                    aspectRatio: _controller!.value.aspectRatio,
+                    child: Stack(
+                      children: [
+                        Image.file(File(_capturedPhoto!.path), fit: BoxFit.cover, width: double.infinity),
+                        Positioned.fill(
+                          child: LayoutBuilder(
+                            builder: (context, constraints) {
+                              return Stack(
+                                children: _detections!
+                                    .map((d) => _buildBoundingBox(d, constraints))
+                                    .toList(),
+                              );
+                            },
+                          ),
+                        ),
+                      ],
+                    ),
+                  )
+                : CameraPreview(_controller!),
+          ),
+
+          // Top UI
+          Positioned(
+            top: 40,
+            left: 20,
+            right: 20,
+            child: Row(
+              mainAxisAlignment: MainAxisAlignment.spaceBetween,
+              children: [
+                CircleAvatar(
+                  backgroundColor: Colors.black54,
+                  child: IconButton(
+                    icon: const Icon(Icons.arrow_back, color: Colors.white),
+                    onPressed: () => Navigator.pop(context),
+                  ),
+                ),
+                if (_capturedPhoto != null)
+                  CircleAvatar(
+                    backgroundColor: Colors.black54,
+                    child: IconButton(
+                      icon: const Icon(Icons.refresh, color: Colors.white),
+                      onPressed: () => setState(() {
+                        _capturedPhoto = null;
+                        _detections = null;
+                      }),
+                    ),
+                  ),
+              ],
+            ),
+          ),
+
+          // Capture Button
+          if (_capturedPhoto == null && !_isAnalyzing)
+            Align(
+              alignment: Alignment.bottomCenter,
+              child: Padding(
+                padding: const EdgeInsets.only(bottom: 48.0),
+                child: GestureDetector(
+                  onTap: _takeAndAnalyze,
+                  child: Container(
+                    height: 80,
+                    width: 80,
+                    padding: const EdgeInsets.all(4),
+                    decoration: BoxDecoration(
+                      shape: BoxShape.circle,
+                      border: Border.all(color: Colors.white, width: 4),
+                    ),
+                    child: Container(
+                      decoration: const BoxDecoration(
+                        color: Colors.white,
+                        shape: BoxShape.circle,
+                      ),
+                    ),
+                  ),
+                ),
+              ),
+            ),
+
+          // Analyzing Overlay
+          if (_isAnalyzing) _buildLoadingOverlay(),
+        ],
+      ),
+    );
+  }
+
+  Widget _buildBoundingBox(DetectionResult detection, BoxConstraints constraints) {
+    final rect = detection.normalizedBox;
+    final color = detection.getStatusColor();
+
+    return Positioned(
+      left: rect.left * constraints.maxWidth,
+      top: rect.top * constraints.maxHeight,
+      width: rect.width * constraints.maxWidth,
+      height: rect.height * constraints.maxHeight,
+      child: Container(
+        decoration: BoxDecoration(
+          border: Border.all(color: color, width: 3),
+          borderRadius: BorderRadius.circular(4),
+        ),
+        child: Align(
+          alignment: Alignment.topLeft,
+          child: Container(
+            padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
+            color: color,
+            child: Text(
+              "${detection.className} ${(detection.confidence * 100).toStringAsFixed(0)}%",
+              style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold),
+            ),
+          ),
+        ),
+      ),
+    );
+  }
+
+  Widget _buildLoadingOverlay() {
+    return Positioned.fill(
+      child: BackdropFilter(
+        filter: ImageFilter.blur(sigmaX: 5, sigmaY: 5),
+        child: Container(
+          color: Colors.black.withOpacity(0.5),
+          child: const Center(
+            child: Column(
+              mainAxisSize: MainAxisSize.min,
+              children: [
+                CircularProgressIndicator(color: Colors.white, strokeWidth: 6),
+                SizedBox(height: 24),
+                Text(
+                  "AI is Analyzing...",
+                  style: TextStyle(color: Colors.white, fontSize: 22, fontWeight: FontWeight.bold),
+                ),
+              ],
+            ),
+          ),
+        ),
+      ),
+    );
+  }
+
+  @override
+  void dispose() {
+    _controller?.dispose();
+    _tfliteService.dispose();
+    super.dispose();
+  }
+}
+
+class _ResultSheet extends StatelessWidget {
+  final PalmRecord record;
+  const _ResultSheet({required this.record});
+
+  @override
+  Widget build(BuildContext context) {
+    Color statusColor = const Color(0xFFFF9800); // Default orange
+    if (record.ripenessClass == 'Empty_Bunch' || record.ripenessClass == 'Abnormal') {
+      statusColor = const Color(0xFFF44336);
+    } else if (record.ripenessClass == 'Ripe' || record.ripenessClass == 'Overripe') {
+      statusColor = const Color(0xFF4CAF50);
+    }
+
+    final bool isWarning = record.ripenessClass == 'Unripe' || record.ripenessClass == 'Underripe';
+
+    return Container(
+      decoration: const BoxDecoration(
+        color: Colors.white,
+        borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
+      ),
+      padding: const EdgeInsets.all(24),
+      child: Column(
+        mainAxisSize: MainAxisSize.min,
+        children: [
+          Container(
+            width: 40,
+            height: 4,
+            decoration: BoxDecoration(color: Colors.grey[300], borderRadius: BorderRadius.circular(2)),
+          ),
+          const SizedBox(height: 24),
+          Row(
+            children: [
+              Icon(Icons.analytics, color: statusColor, size: 32),
+              const SizedBox(width: 12),
+              const Text("Analysis Result", style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
+            ],
+          ),
+          const SizedBox(height: 24),
+          _buildInfoRow("Ripeness", record.ripenessClass, statusColor),
+          _buildInfoRow("Confidence", "${(record.confidence * 100).toStringAsFixed(1)}%", Colors.grey[700]!),
+          
+          if (record.ripenessClass == 'Empty_Bunch' || record.ripenessClass == 'Abnormal')
+            _buildAlertCard("HEALTH ALERT", "Abnormal features detected. Check palm health.", Colors.red),
+          
+          if (isWarning)
+            _buildAlertCard("YIELD WARNING", "Potential Yield Loss due to premature harvest.", Colors.orange),
+
+          const SizedBox(height: 32),
+          SizedBox(
+            width: double.infinity,
+            child: ElevatedButton(
+              onPressed: () => Navigator.pop(context),
+              style: ElevatedButton.styleFrom(
+                backgroundColor: statusColor,
+                foregroundColor: Colors.white,
+                padding: const EdgeInsets.symmetric(vertical: 16),
+                shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
+              ),
+              child: const Text("Acknowledge", style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
+            ),
+          ),
+          const SizedBox(height: 12),
+        ],
+      ),
+    );
+  }
+
+  Widget _buildInfoRow(String label, String value, Color color) {
+    return Padding(
+      padding: const EdgeInsets.symmetric(vertical: 8.0),
+      child: Row(
+        mainAxisAlignment: MainAxisAlignment.spaceBetween,
+        children: [
+          Text(label, style: const TextStyle(fontSize: 16, color: Colors.grey)),
+          Text(value, style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: color)),
+        ],
+      ),
+    );
+  }
+
+  Widget _buildAlertCard(String title, String message, Color color) {
+    return Container(
+      margin: const EdgeInsets.only(top: 16),
+      padding: const EdgeInsets.all(16),
+      decoration: BoxDecoration(
+        color: color.withOpacity(0.1),
+        borderRadius: BorderRadius.circular(12),
+        border: Border.all(color: color.withOpacity(0.3)),
+      ),
+      child: Row(
+        children: [
+          Icon(Icons.warning_amber_rounded, color: color),
+          const SizedBox(width: 12),
+          Expanded(
+            child: Column(
+              crossAxisAlignment: CrossAxisAlignment.start,
+              children: [
+                Text(title, style: TextStyle(color: color, fontWeight: FontWeight.bold, fontSize: 14)),
+                Text(message, style: TextStyle(color: color.withOpacity(0.8), fontSize: 12)),
+              ],
+            ),
+          ),
+        ],
+      ),
+    );
+  }
+}

+ 19 - 15
palm_oil_mobile/lib/services/tflite_service.dart

@@ -1,6 +1,7 @@
 import 'dart:io';
 import 'dart:math';
 import 'dart:ui';
+import 'dart:typed_data';
 import 'dart:isolate';
 import 'dart:async';
 import 'package:flutter/services.dart';
@@ -46,6 +47,7 @@ class TfliteService {
   bool _isIsolateBusy = false;
 
   bool get isInitialized => _isInitialized;
+  bool get isIsolateBusy => _isIsolateBusy;
 
   Future<void> initModel() async {
     try {
@@ -124,10 +126,14 @@ class TfliteService {
 
   Future<List<DetectionResult>> runInferenceOnStream(CameraImage image) async {
     if (!_isInitialized) await initModel();
+
+    // The gatekeeper logic has moved up to LiveAnalysisScreen (Atomic Lock)
+    // but we keep the safety bypass here just in case.
     if (_isIsolateBusy) return <DetectionResult>[];
 
     _isIsolateBusy = true;
     final replyPort = ReceivePort();
+    
     _sendPort!.send({
       'command': 'inference_stream',
       'planes': image.planes.map((p) => {
@@ -329,9 +335,8 @@ class TfliteService {
     final uvRowStride = uPlane['bytesPerRow'] as int;
     final uvPixelStride = uPlane['bytesPerPixel'] as int;
 
-    // Use a flat Uint8List buffer for fast native-style memory writing
-    // 3 channels: R, G, B
-    final Uint8List rgbBytes = Uint8List(cropSize * cropSize * 3);
+    // Fast 32-bit Native memory buffer
+    final Uint32List bgraData = Uint32List(cropSize * cropSize);
     int bufferIndex = 0;
 
     for (int y = 0; y < cropSize; y++) {
@@ -339,14 +344,11 @@ class TfliteService {
         final int actualX = x + offsetX;
         final int actualY = y + offsetY;
 
-        // Mathematical offset matching
         final int uvIndex = (uvRowStride * (actualY >> 1)) + (uvPixelStride * (actualX >> 1));
         final int yIndex = (actualY * yRowStride) + actualX;
 
-        // Skip if out of bounds (should not happen mathematically if offsets are valid, 
-        // but kept as safety check for corrupted frames)
         if (yIndex >= yBytes.length || uvIndex >= uBytes.length || uvIndex >= vBytes.length) {
-            bufferIndex += 3;
+            bufferIndex++;
             continue;
         }
 
@@ -359,21 +361,23 @@ class TfliteService {
         int g = (yp - (0.337633 * (up - 128)) - (0.698001 * (vp - 128))).toInt();
         int b = (yp + (1.732446 * (up - 128))).toInt();
 
-        // Write directly to sequential memory with inline clamping
-        rgbBytes[bufferIndex++] = r < 0 ? 0 : (r > 255 ? 255 : r);
-        rgbBytes[bufferIndex++] = g < 0 ? 0 : (g > 255 ? 255 : g);
-        rgbBytes[bufferIndex++] = b < 0 ? 0 : (b > 255 ? 255 : b);
+        // Clamp inline for max speed
+        r = r < 0 ? 0 : (r > 255 ? 255 : r);
+        g = g < 0 ? 0 : (g > 255 ? 255 : g);
+        b = b < 0 ? 0 : (b > 255 ? 255 : b);
+
+        // Pack into 32-bit integer: 0xAARRGGBB -> Memory writes it Little Endian: B, G, R, A.
+        bgraData[bufferIndex++] = (255 << 24) | (r << 16) | (g << 8) | b;
       }
     }
     
-    // Construct image mapping directly from the fast buffer
     return img.Image.fromBytes(
       width: cropSize, 
       height: cropSize, 
-      bytes: rgbBytes.buffer,
+      bytes: bgraData.buffer,
       format: img.Format.uint8,
-      numChannels: 3,
-      order: img.ChannelOrder.rgb,
+      numChannels: 4,               // Packed 4 channels (BGRA)
+      order: img.ChannelOrder.bgra, // Explicitly tell image package it's BGRA
     );
   }