card-recog-demo/carddex_flutter_demo_plan.md
a1518 ccf8eeb21d Initial commit: Flutter card recognition demo
Camera capture, corner detection/refinement, and preview crop pipeline for trading cards.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 20:17:32 -07:00

14 KiB
Raw Blame History

CardDex 卡牌检测 Demo — Flutter 开发方案

目标:复刻 CardDex 本地 TF Lite 目标检测 + 卡片定位能力
周期~3.5 天(单人)
依赖CardDex APK 内提取的 detect.tfliteCOCO 通用检测模型4MB


一、架构概览

┌──────────────────────────────────────┐
│  Flutter UI                           │
│  camera_preview + detection_overlay   │
├──────────────────────────────────────┤
│  TFLite Detector Service              │
│  ├─ 模型加载 (detect.tflite)           │
│  ├─ 图像预处理 (300×300 RGB)           │
│  ├─ 推理 (输出 boxes/classes/scores)   │
│  └─ NMS 后处理 (IoU 去重)             │
├──────────────────────────────────────┤
│  Camera Plugin (拍照)                  │
└──────────────────────────────────────┘

核心流程:拍照 → 缩放到 300×300 → TF Lite 推理 → NMS 去重 → 画框展示


二、项目结构

carddex_demo/
├── pubspec.yaml
├── lib/
│   ├── main.dart                      # 入口 + 相机预览
│   ├── services/
│   │   ├── detector_service.dart      # TF Lite 加载/推理
│   │   └── image_processor.dart       # 图像预处理
│   ├── utils/
│   │   └── nms.dart                   # NMS 非极大值抑制
│   └── widgets/
│       └── detection_painter.dart     # 检测框叠加渲染
└── assets/
    └── ml/
        ├── detect.tflite              # 从 CardDex APK 提取
        └── labelmap.txt               # COCO 90 类标签

三、依赖清单

# pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  camera: ^0.10.5              # 相机采集
  tflite_flutter: ^0.11.0      # TF Lite 推理
  image: ^4.1.0                # Dart 侧图像处理

flutter:
  assets:
    - assets/ml/detect.tflite
    - assets/ml/labelmap.txt

四、核心代码骨架

4.1 主入口 — main.dart

import 'package:flutter/material.dart';
import 'package:camera/camera.dart';
import 'services/detector_service.dart';
import 'widgets/detection_painter.dart';

late List<CameraDescription> _cameras;

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  _cameras = await availableCameras();
  await DetectorService.instance.initialize();
  runApp(const CardDexDemo());
}

class CardDexDemo extends StatelessWidget {
  const CardDexDemo({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: ScannerScreen(camera: _cameras.first),
    );
  }
}

class ScannerScreen extends StatefulWidget {
  final CameraDescription camera;
  const ScannerScreen({super.key, required this.camera});

  @override
  State<ScannerScreen> createState() => _ScannerScreenState();
}

class _ScannerScreenState extends State<ScannerScreen> {
  late CameraController _controller;
  List<Detection> _detections = [];
  bool _isProcessing = false;

  @override
  void initState() {
    super.initState();
    _controller = CameraController(
      widget.camera,
      ResolutionPreset.high,
    );
    _controller.initialize().then((_) => setState(() {}));
  }

  Future<void> _takePicture() async {
    if (_isProcessing) return;
    setState(() => _isProcessing = true);

    final xFile = await _controller.takePicture();
    final bytes = await xFile.readAsBytes();

    // 推理
    final detections = await DetectorService.instance.detect(bytes);
    
    setState(() {
      _detections = detections;
      _isProcessing = false;
    });
  }

  @override
  Widget build(BuildContext context) {
    if (!_controller.value.isInitialized) {
      return const Center(child: CircularProgressIndicator());
    }

    return Scaffold(
      body: Stack(
        fit: StackFit.expand,
        children: [
          CameraPreview(_controller),
          if (_detections.isNotEmpty)
            CustomPaint(
              painter: DetectionPainter(_detections),
            ),
          Positioned(
            bottom: 40,
            left: 0,
            right: 0,
            child: Center(
              child: FloatingActionButton(
                onPressed: _isProcessing ? null : _takePicture,
                child: _isProcessing
                    ? const CircularProgressIndicator(color: Colors.white)
                    : const Icon(Icons.camera),
              ),
            ),
          ),
        ],
      ),
    );
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }
}

4.2 检测服务 — detector_service.dart

import 'dart:io';
import 'package:tflite_flutter/tflite_flutter.dart';
import 'package:image/image.dart' as img;
import '../utils/nms.dart';
import 'image_processor.dart';

class Detection {
  final Rect boundingBox;
  final String label;
  final double confidence;

  Detection({
    required this.boundingBox,
    required this.label,
    required this.confidence,
  });
}

class DetectorService {
  static final DetectorService instance = DetectorService._();
  DetectorService._();

  Interpreter? _interpreter;
  List<String> _labels = [];
  bool _isInitialized = false;

  // 模型输入尺寸COCO SSD MobileNet 标准)
  static const int inputSize = 300;
  static const double confidenceThreshold = 0.5;

  Future<void> initialize() async {
    if (_isInitialized) return;

    // 加载模型
    _interpreter = await Interpreter.fromAsset('assets/ml/detect.tflite');

    // 加载标签
    final labelData = await rootBundle.loadString('assets/ml/labelmap.txt');
    _labels = labelData
        .split('\n')
        .map((l) => l.trim())
        .where((l) => l.isNotEmpty)
        .toList();

    _isInitialized = true;
  }

  Future<List<Detection>> detect(Uint8List imageBytes) async {
    if (!_isInitialized) throw Exception('Detector not initialized');

    // 1. 图像预处理
    final input = ImageProcessor.preprocess(imageBytes, inputSize);

    // 2. 分配输出张量
    // SSD MobileNet 输出: [1, num_detections, 4] boxes, [1, num_detections] classes, [1, num_detections] scores
    final outputBoxes = List.filled(1 * 10 * 4, 0.0).reshape([1, 10, 4]);
    final outputClasses = List.filled(1 * 10, 0.0).reshape([1, 10]);
    final outputScores = List.filled(1 * 10, 0.0).reshape([1, 10]);
    final numDetections = List.filled(1, 0.0).reshape([1]);

    // 3. 推理
    _interpreter!.runForMultipleInputs([input], {
      0: outputBoxes,
      1: outputClasses,
      2: outputScores,
      3: numDetections,
    });

    // 4. 解析结果
    final boxes = <List<double>>[];
    final classes = <int>[];
    final scores = <double>[];

    final count = numDetections[0][0].toInt().clamp(0, 10);
    for (int i = 0; i < count; i++) {
      if (outputScores[0][i] >= confidenceThreshold) {
        boxes.add([
          outputBoxes[0][i][1], // ymin
          outputBoxes[0][i][0], // xmin
          outputBoxes[0][i][3], // ymax
          outputBoxes[0][i][2], // xmax
        ]);
        classes.add(outputClasses[0][i].toInt());
        scores.add(outputScores[0][i]);
      }
    }

    // 5. NMS 去重
    final indices = NMS.suppress(boxes, scores, iouThreshold: 0.5);

    // 6. 生成检测结果
    return indices.map((i) {
      final box = boxes[i];
      return Detection(
        boundingBox: Rect.fromLTRB(
          box[1] * inputSize, // xmin * width
          box[0] * inputSize, // ymin * height
          box[3] * inputSize, // xmax * width
          box[2] * inputSize, // ymax * height
        ),
        label: classes[i] < _labels.length ? _labels[classes[i]] : 'unknown',
        confidence: scores[i],
      );
    }).toList();
  }
}

4.3 图像预处理 — image_processor.dart

import 'dart:typed_data';
import 'package:image/image.dart' as img;

class ImageProcessor {
  /// 将拍照得到的 JPEG bytes 预处理为 TF Lite 输入张量
  /// 返回 [1, 300, 300, 3] 的 Float32List归一化到 [0,1]
  static Float32List preprocess(Uint8List jpegBytes, int targetSize) {
    // 1. 解码 JPEG
    final image = img.decodeJpg(jpegBytes);
    if (image == null) throw Exception('Failed to decode image');

    // 2. 缩放到 300×300
    final resized = img.copyResize(image, width: targetSize, height: targetSize);

    // 3. RGB → Float32List归一化到 [0, 1]
    final input = Float32List(1 * targetSize * targetSize * 3);
    int pixelIndex = 0;
    for (int y = 0; y < targetSize; y++) {
      for (int x = 0; x < targetSize; x++) {
        final pixel = resized.getPixel(x, y);
        input[pixelIndex++] = pixel.r / 255.0;
        input[pixelIndex++] = pixel.g / 255.0;
        input[pixelIndex++] = pixel.b / 255.0;
      }
    }

    return input;
  }
}

4.4 NMS 去重 — nms.dart

class NMS {
  /// 贪心 NMS按 score 降序,保留 IoU < threshold 的框
  static List<int> suppress(
    List<List<double>> boxes,
    List<double> scores, {
    double iouThreshold = 0.5,
  }) {
    // 按分数排序
    final indices = List.generate(scores.length, (i) => i);
    indices.sort((a, b) => scores[b].compareTo(scores[a]));

    final selected = <int>[];
    final suppressed = List.filled(boxes.length, false);

    for (final idx in indices) {
      if (suppressed[idx]) continue;
      selected.add(idx);

      for (int j = 0; j < boxes.length; j++) {
        if (j == idx || suppressed[j]) continue;
        if (_iou(boxes[idx], boxes[j]) > iouThreshold) {
          suppressed[j] = true;
        }
      }
    }

    return selected;
  }

  /// 计算两个框的 IoU
  static double _iou(List<double> a, List<double> b) {
    final x1 = max(a[1], b[1]);
    final y1 = max(a[0], b[0]);
    final x2 = min(a[3], b[3]);
    final y2 = min(a[2], b[2]);

    if (x2 <= x1 || y2 <= y1) return 0.0;

    final interArea = (x2 - x1) * (y2 - y1);
    final areaA = (a[3] - a[1]) * (a[2] - a[0]);
    final areaB = (b[3] - b[1]) * (b[2] - b[0]);

    return interArea / (areaA + areaB - interArea);
  }
}

4.5 检测框渲染 — detection_painter.dart

import 'package:flutter/material.dart';
import '../services/detector_service.dart';

class DetectionPainter extends CustomPainter {
  final List<Detection> detections;

  DetectionPainter(this.detections);

  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()
      ..color = Colors.green
      ..style = PaintingStyle.stroke
      ..strokeWidth = 3.0;

    final textStyle = TextStyle(
      color: Colors.white,
      fontSize: 14,
      backgroundColor: Colors.green.withOpacity(0.8),
    );

    for (final det in detections) {
      // 画框
      canvas.drawRect(det.boundingBox, paint);

      // 画标签
      final tp = TextPainter(
        text: TextSpan(
          text: '${det.label} ${(det.confidence * 100).toStringAsFixed(0)}%',
          style: textStyle,
        ),
        textDirection: TextDirection.ltr,
      );
      tp.layout();
      tp.paint(
        canvas,
        Offset(det.boundingBox.left, det.boundingBox.top - tp.height),
      );
    }
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}

五、模型提取

# 从 CardDex APK 提取模型和标签
cd apk_analysis/recognize/apk_extracted_carddex
unzip com.card.dex.identifier.detect.apk \
  "assets/flutter_assets/assets/ml/detect.tflite" \
  "assets/flutter_assets/assets/ml/labelmap.txt" \
  -d ../../../carddex_demo/assets/ml/

六、构建与运行

# 1. 创建 Flutter 项目
flutter create carddex_demo
cd carddex_demo

# 2. 添加依赖(编辑 pubspec.yaml

# 3. 复制模型文件到 assets/ml/

# 4. 替换 lib/ 下的代码

# 5. 运行(需要真机,模拟器无相机)
flutter run

七、关键注意事项

7.1 模型输出格式适配

CardDex 用的是 SSD MobileNet输出张量取决于具体的 TFLite 模型。如果上面的输出索引不对,需要先用工具查看:

# 查看模型输入输出信息
python3 -c "
import tensorflow as tf
interpreter = tf.lite.Interpreter(model_path='assets/ml/detect.tflite')
print('Input:', interpreter.get_input_details())
print('Output:', interpreter.get_output_details())
"

7.2 平台配置

iOS (ios/Podfile):确保最低版本 ≥ 12.0,并添加相机权限描述。
Android (android/app/build.gradle)minSdkVersion ≥ 21。

7.3 性能

  • detect.tflite ~4MB推理耗时约 50-200ms取决于设备
  • 拍照后单次推理,不做实时帧,性能无忧
  • GPU delegate 可选,配置方法:Interpreter.fromAsset('detect.tflite', options: InterpreterOptions()..useGpuDelegateWhenAvailable = true)

7.4 局限

局限 说明 后续方向
只能定位,不能分类 COCO 标签里没有"卡牌",检测框内的物体不知道是什么卡 自训卡牌分类模型或接 API
模型不针对卡牌优化 卡片是矩形平面物体COCO 模型对此场景精度一般 用卡牌数据 fine-tune
多卡同框效果差 SSD 对小目标和密集目标检测能力有限 换 YOLO-NAS 或 RT-DETR

八、Demo 验收标准

  • 打开 App 显示相机预览
  • 对准一张卡牌拍照
  • 画面出现绿色检测框 + 标签 + 置信度
  • 对准其他物体(杯子、手机等)也能检测
  • 无明显崩溃或 ANR

九、进阶方向Demo 后)

阶段 内容 预估
Phase 2 替换为卡牌专用检测模型(自训练) 1 周
Phase 3 接入服务端 API 做卡牌分类 3 天
Phase 4 实时帧推理 + GPU 加速 3 天
Phase 5 多卡同框检测 + 批量识别 1 周