浏览代码

feat: Introduce TFLite service for palm oil fruit object detection and add main application screens.

Dr-Swopt 1 周之前
父节点
当前提交
c98457466d

+ 4 - 3
palm_oil_mobile/lib/screens/analysis_screen.dart

@@ -235,6 +235,7 @@ class _AnalysisScreenState extends State<AnalysisScreen> with SingleTickerProvid
 
   // Removed _getStatusColor local method in favor of DetectionResult.getStatusColor()
 
+
   Widget _buildResultSummary() {
     final best = _detections!.first;
     return Container(
@@ -288,7 +289,7 @@ class _AnalysisScreenState extends State<AnalysisScreen> with SingleTickerProvid
       child: BackdropFilter(
         filter: ImageFilter.blur(sigmaX: 5, sigmaY: 5),
         child: Container(
-          color: Colors.black.withValues(alpha: 0.3),
+          color: Colors.black.withOpacity(0.3),
           child: const Center(
             child: Column(
               mainAxisSize: MainAxisSize.min,
@@ -388,13 +389,13 @@ class _ScanningOverlay extends StatelessWidget {
                   gradient: LinearGradient(
                     colors: [
                       Colors.transparent,
-                      Colors.greenAccent.withValues(alpha: 0.8),
+                      Colors.greenAccent.withOpacity(0.8),
                       Colors.transparent,
                     ],
                   ),
                   boxShadow: [
                     BoxShadow(
-                      color: Colors.greenAccent.withValues(alpha: 0.6),
+                      color: Colors.greenAccent.withOpacity(0.6),
                       blurRadius: 15,
                       spreadRadius: 2,
                     ),

+ 1 - 1
palm_oil_mobile/lib/screens/history_screen.dart

@@ -122,7 +122,7 @@ class _HistoryScreenState extends State<HistoryScreen> {
                       shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15)),
                       child: ListTile(
                         leading: CircleAvatar(
-                          backgroundColor: statusColor.withValues(alpha: 0.2),
+                          backgroundColor: statusColor.withOpacity(0.2),
                           child: Icon(Icons.spa, color: statusColor),
                         ),
                         title: Text(

+ 3 - 3
palm_oil_mobile/lib/screens/home_screen.dart

@@ -86,19 +86,19 @@ class HomeScreen extends StatelessWidget {
           borderRadius: BorderRadius.circular(20),
           boxShadow: [
             BoxShadow(
-              color: color.withValues(alpha: 0.1),
+              color: color.withOpacity(0.1),
               blurRadius: 15,
               offset: const Offset(0, 8),
             ),
           ],
-          border: Border.all(color: color.withValues(alpha: 0.2), width: 1),
+          border: Border.all(color: color.withOpacity(0.2), width: 1),
         ),
         child: Row(
           children: [
             Container(
               padding: const EdgeInsets.all(12),
               decoration: BoxDecoration(
-                color: color.withValues(alpha: 0.1),
+                color: color.withOpacity(0.1),
                 borderRadius: BorderRadius.circular(15),
               ),
               child: Icon(icon, color: color, size: 30),

+ 12 - 13
palm_oil_mobile/lib/screens/live_analysis_screen.dart

@@ -31,8 +31,8 @@ class _LiveAnalysisScreenState extends State<LiveAnalysisScreen> {
   static const double _lockThreshold = 0.60;
   static const int _frameThrottle = 2; // Check frames more frequently
   
-  int _consecutiveDetections = 0;
-  static const int _requiredConsecutive = 3; // Number of frames to hold
+  final List<bool> _detectionHistory = List.filled(10, false, growable: true);
+  static const int _requiredHits = 4; // 4 out of 10 for a lock
 
   @override
   void initState() {
@@ -49,7 +49,7 @@ class _LiveAnalysisScreenState extends State<LiveAnalysisScreen> {
 
     _controller = CameraController(
       cameras[0],
-      ResolutionPreset.medium,
+      ResolutionPreset.medium, // Restoring to a valid preset
       enableAudio: false,
       imageFormatGroup: Platform.isAndroid ? ImageFormatGroup.yuv420 : ImageFormatGroup.bgra8888,
     );
@@ -86,18 +86,16 @@ class _LiveAnalysisScreenState extends State<LiveAnalysisScreen> {
         currentFrameHasFruit = detections.any((d) => d.confidence > _lockThreshold);
       }
 
-      if (currentFrameHasFruit) {
-        _consecutiveDetections++;
-      } else {
-        _consecutiveDetections--; // Just drop by one, don't kill the whole lock
-      }
-      
-      _consecutiveDetections = _consecutiveDetections.clamp(0, _requiredConsecutive);
+      // Update Sliding Window Buffer
+      _detectionHistory.removeAt(0);
+      _detectionHistory.add(currentFrameHasFruit);
+
+      final hits = _detectionHistory.where((h) => h).length;
 
       if (mounted) {
         setState(() {
           _detections = detections;
-          _isLocked = _consecutiveDetections >= _requiredConsecutive;
+          _isLocked = hits >= _requiredHits;
         });
       }
     } catch (e) {
@@ -319,7 +317,7 @@ class _LiveAnalysisScreenState extends State<LiveAnalysisScreen> {
                   decoration: BoxDecoration(
                     shape: BoxShape.circle,
                     border: Border.all(color: Colors.white, width: 4),
-                    color: _isLocked ? Colors.green.withValues(alpha: 0.8) : Colors.white24,
+                    color: _isLocked ? Colors.green.withOpacity(0.8) : Colors.white24,
                   ),
                   child: Icon(
                     _isLocked ? Icons.camera_alt : Icons.hourglass_empty,
@@ -350,7 +348,8 @@ class _LiveAnalysisScreenState extends State<LiveAnalysisScreen> {
 
   Widget _buildOverlayBox(DetectionResult detection, BoxConstraints constraints) {
     final rect = detection.normalizedBox;
-    final color = detection.confidence > _lockThreshold ? Colors.green : Colors.yellow;
+    // Show green only if the system is overall "Locked" and this detection is high confidence
+    final color = (_isLocked && detection.confidence > _lockThreshold) ? Colors.green : Colors.yellow;
 
     return Positioned(
       left: rect.left * constraints.maxWidth,

+ 87 - 26
palm_oil_mobile/lib/services/tflite_service.dart

@@ -110,7 +110,6 @@ class TfliteService {
   }
 
   static List<DetectionResult> _inferenceStreamTaskWrapper(Map<String, dynamic> args) {
-    // This is a simplified wrapper for stream inference in isolate
     final modelBytes = args['modelBytes'] as Uint8List;
     final labelData = args['labelData'] as String;
     final planes = args['planes'] as List<dynamic>;
@@ -121,16 +120,22 @@ class TfliteService {
     final labels = labelData.split('\n').where((l) => l.trim().isNotEmpty).map((l) => l.trim()).toList();
 
     try {
-      // Manual YUV to RGB conversion if needed, or use image package if possible
-      // For speed in stream, we might want a more optimized conversion.
-      // But for now, let's use a basic one or the image package.
-      
+      final size = width < height ? width : height;
+      final offsetX = (width - size) ~/ 2;
+      final offsetY = (height - size) ~/ 2;
+
       img.Image? image;
       if (args['format'] == ImageFormatGroup.yuv420) {
-        // Simple YUV420 to RGB (this is slow in Dart, but better in isolate)
-        image = _convertYUV420ToImage(planes, width, height);
+        image = _convertYUV420ToImage(
+          planes: planes,
+          width: width,
+          height: height,
+          cropSize: size,
+          offsetX: offsetX,
+          offsetY: offsetY,
+        );
       } else if (args['format'] == ImageFormatGroup.bgra8888) {
-        image = img.Image.fromBytes(
+        final fullImage = img.Image.fromBytes(
           width: width,
           height: height,
           bytes: planes[0]['bytes'].buffer,
@@ -138,6 +143,7 @@ class TfliteService {
           numChannels: 4,
           order: img.ChannelOrder.bgra,
         );
+        image = img.copyCrop(fullImage, x: offsetX, y: offsetY, width: size, height: size);
       }
 
       if (image == null) return [];
@@ -162,13 +168,30 @@ class TfliteService {
       );
 
       interpreter.run(inputTensor, outputTensor);
-      return _decodeDetections(outputTensor[0], labels);
+      
+      // Map detections back to full frame
+      return _decodeDetections(
+        outputTensor[0], 
+        labels, 
+        cropSize: size, 
+        offsetX: offsetX, 
+        offsetY: offsetY, 
+        fullWidth: width, 
+        fullHeight: height
+      );
     } finally {
       interpreter.close();
     }
   }
 
-  static img.Image _convertYUV420ToImage(List<dynamic> planes, int width, int height) {
+  static img.Image _convertYUV420ToImage({
+    required List<dynamic> planes,
+    required int width,
+    required int height,
+    required int cropSize,
+    required int offsetX,
+    required int offsetY,
+  }) {
     final yPlane = planes[0];
     final uPlane = planes[1];
     final vPlane = planes[2];
@@ -181,12 +204,18 @@ class TfliteService {
     final uvRowStride = uPlane['bytesPerRow'] as int;
     final uvPixelStride = uPlane['bytesPerPixel'] as int;
 
-    final image = img.Image(width: width, height: height);
+    final image = img.Image(width: cropSize, height: cropSize);
+
+    for (int y = 0; y < cropSize; y++) {
+      for (int x = 0; x < cropSize; x++) {
+        final int actualX = x + offsetX;
+        final int actualY = y + offsetY;
 
-    for (int y = 0; y < height; y++) {
-      for (int x = 0; x < width; x++) {
-        final int uvIndex = (uvRowStride * (y / 2).floor()) + (uvPixelStride * (x / 2).floor());
-        final int yIndex = (y * yRowStride) + x;
+        final int uvIndex = (uvRowStride * (actualY / 2).floor()) + (uvPixelStride * (actualX / 2).floor());
+        final int yIndex = (actualY * yRowStride) + actualX;
+
+        // Ensure we don't go out of bounds
+        if (yIndex >= yBytes.length || uvIndex >= uBytes.length || uvIndex >= vBytes.length) continue;
 
         final int yp = yBytes[yIndex];
         final int up = uBytes[uvIndex];
@@ -203,17 +232,34 @@ class TfliteService {
     return image;
   }
 
-  static List<DetectionResult> _decodeDetections(List<List<double>> rawDetections, List<String> labels) {
+  static List<DetectionResult> _decodeDetections(
+    List<List<double>> rawDetections, 
+    List<String> labels, {
+    int? cropSize,
+    int? offsetX,
+    int? offsetY,
+    int? fullWidth,
+    int? fullHeight,
+  }) {
     final detections = <DetectionResult>[];
     for (final det in rawDetections) {
       if (det.length < 6) continue;
       final conf = det[4];
       if (conf < _confidenceThreshold) continue;
 
-      final x1 = det[0].clamp(0.0, 1.0);
-      final y1 = det[1].clamp(0.0, 1.0);
-      final x2 = det[2].clamp(0.0, 1.0);
-      final y2 = det[3].clamp(0.0, 1.0);
+      double x1 = det[0].clamp(0.0, 1.0);
+      double y1 = det[1].clamp(0.0, 1.0);
+      double x2 = det[2].clamp(0.0, 1.0);
+      double y2 = det[3].clamp(0.0, 1.0);
+      
+      // If crop info is provided, map back to full frame
+      if (cropSize != null && offsetX != null && offsetY != null && fullWidth != null && fullHeight != null) {
+        x1 = (x1 * cropSize + offsetX) / fullWidth;
+        x2 = (x2 * cropSize + offsetX) / fullWidth;
+        y1 = (y1 * cropSize + offsetY) / fullHeight;
+        y2 = (y2 * cropSize + offsetY) / fullHeight;
+      }
+
       final classId = det[5].round();
 
       if (x2 <= x1 || y2 <= y1) continue;
@@ -263,7 +309,15 @@ class TfliteService {
       final decoded = img.decodeImage(imageBytes);
       if (decoded == null) throw Exception('Could not decode image');
 
-      final resized = img.copyResize(decoded, width: _inputSize, height: _inputSize, interpolation: img.Interpolation.linear);
+      // Center-Square Crop
+      final int width = decoded.width;
+      final int height = decoded.height;
+      final int size = width < height ? width : height;
+      final int offsetX = (width - size) ~/ 2;
+      final int offsetY = (height - size) ~/ 2;
+      
+      final cropped = img.copyCrop(decoded, x: offsetX, y: offsetY, width: size, height: size);
+      final resized = img.copyResize(cropped, width: _inputSize, height: _inputSize, interpolation: img.Interpolation.linear);
 
       final inputTensor = List.generate(1, (_) =>
         List.generate(_inputSize, (y) =>
@@ -276,18 +330,25 @@ class TfliteService {
 
       // 3. Prepare output
       final outputShape = interpreter.getOutputTensors()[0].shape;
-      final numDetections = outputShape[1];
-      final numFields = outputShape[2];
       final outputTensor = List.generate(1, (_) =>
-        List.generate(numDetections, (_) =>
-          List<double>.filled(numFields, 0.0)
+        List.generate(outputShape[1], (_) =>
+          List<double>.filled(outputShape[2], 0.0)
         )
       );
 
       // 4. Run
       interpreter.run(inputTensor, outputTensor);
 
-      return _decodeDetections(outputTensor[0], labels);
+      // Map detections back to full frame
+      return _decodeDetections(
+        outputTensor[0], 
+        labels, 
+        cropSize: size, 
+        offsetX: offsetX, 
+        offsetY: offsetY, 
+        fullWidth: width, 
+        fullHeight: height
+      );
     } finally {
       interpreter.close();
     }

+ 0 - 56
palm_oil_mobile/lib/services/yolo_service.dart

@@ -1,56 +0,0 @@
-import 'dart:typed_data';
-import 'dart:io';
-import 'package:image_picker/image_picker.dart';
-import 'package:ultralytics_yolo/ultralytics_yolo.dart';
-
-class YoloService {
-  late YOLO _yolo;
-  final ImagePicker _picker = ImagePicker();
-  bool _isInitialized = false;
-
-  Future<void> initModel() async {
-    try {
-      _yolo = YOLO(
-        modelPath: 'best.tflite',
-        task: YOLOTask.detect,
-        useGpu: false, // Disabling GPU for better stability on budget devices
-      );
-      final success = await _yolo.loadModel();
-      if (!success) {
-        throw Exception("Model failed to load.");
-      }
-      _isInitialized = true;
-    } catch (e) {
-      print("YOLO Init Error: $e");
-      rethrow;
-    }
-  }
-
-  Future<XFile?> pickImage() async {
-    return await _picker.pickImage(
-      source: ImageSource.gallery,
-      maxWidth: 640,
-      maxHeight: 640,
-    );
-  }
-
-  Future<List<dynamic>?> runInference(String imagePath) async {
-    if (!_isInitialized) {
-      await initModel();
-    }
-    
-    try {
-      final File imageFile = File(imagePath);
-      final Uint8List bytes = await imageFile.readAsBytes();
-      final results = await _yolo.predict(bytes);
-      return results['detections'] as List<dynamic>?;
-    } catch (e) {
-      print("YOLO Inference Error: $e");
-      rethrow;
-    }
-  }
-
-  void dispose() {
-    // No explicit close on YOLO class
-  }
-}

+ 0 - 8
palm_oil_mobile/pubspec.lock

@@ -677,14 +677,6 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "1.4.0"
-  ultralytics_yolo:
-    dependency: "direct main"
-    description:
-      name: ultralytics_yolo
-      sha256: "0fc5a8385c3c7fcd5266da6a62184935dbc3619b8ad0813fd30d588ffeec85ee"
-      url: "https://pub.dev"
-    source: hosted
-    version: "0.2.0"
   vector_math:
     dependency: transitive
     description:

+ 0 - 1
palm_oil_mobile/pubspec.yaml

@@ -40,7 +40,6 @@ dependencies:
   sqflite: ^2.3.0
   path_provider: ^2.1.2
   path: ^1.9.0
-  ultralytics_yolo: ^0.2.0
   tflite_flutter: ^0.12.1
   image: ^4.8.0