514 lines
14 KiB
Markdown
514 lines
14 KiB
Markdown
|
|
# 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 周 |
|