card-recog-demo/carddex_flutter_demo_plan.md

514 lines
14 KiB
Markdown
Raw Normal View History

# CardDex 卡牌检测 Demo — Flutter 开发方案
> **目标**:复刻 CardDex 本地 TF Lite 目标检测 + 卡片定位能力
> **周期**~3.5 天(单人)
> **依赖**CardDex APK 内提取的 `detect.tflite`COCO 通用检测模型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 类标签
```
---
## 三、依赖清单
```yaml
# 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`
```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`
```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`
```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`
```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`
```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;
}
```
---
## 五、模型提取
```bash
# 从 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/
```
---
## 六、构建与运行
```bash
# 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 模型。如果上面的输出索引不对,需要先用工具查看:
```bash
# 查看模型输入输出信息
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 周 |