训练体系整合与标注单阶段化
- 标注:AI 预标注直写 labels_json(去候选确认两阶段);重叠去重(minIoU);全量标注按钮 - 训练:脚本迁移入 server/training/(Go 化 prepare_yolo/analyze_rfdetr,保留 train_server.py);tflite 产物自检并入训练流程(check_tflite) - 数据目录/权重不进 git;.gitignore 迁移至仓库根
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:crypto/crypto.dart' show sha256;
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:observer/models/model_manager.dart';
|
||||
|
||||
const _modelBytes = [1, 2, 3, 4, 5, 6, 7, 8];
|
||||
|
||||
String _shaHex(List<int> bytes) => sha256.convert(bytes).toString();
|
||||
|
||||
Map<String, dynamic> _catalog(List<Map<String, dynamic>> models) =>
|
||||
{'code': 0, 'message': 'ok', 'data': {'version': '0.0.2', 'notes': '', 'models': models}};
|
||||
|
||||
Map<String, dynamic> _item({String version = 'v1.0.0', String sha = ''}) => {
|
||||
'datasetId': 7,
|
||||
'datasetName': '野鸡数据集',
|
||||
'version': version,
|
||||
'labels': ['pheasant', 'suspect'],
|
||||
'sizeBytes': _modelBytes.length,
|
||||
'sha256': sha.isEmpty ? _shaHex(_modelBytes) : sha,
|
||||
'downloadUrl': '/download/models/野鸡数据集/latest.tflite',
|
||||
};
|
||||
|
||||
void main() {
|
||||
late Directory root;
|
||||
late int downloadHits;
|
||||
|
||||
setUp(() async {
|
||||
root = await Directory.systemTemp.createTemp('model-manager-test');
|
||||
downloadHits = 0;
|
||||
});
|
||||
|
||||
tearDown(() => root.delete(recursive: true));
|
||||
|
||||
ModelManager manager(MockClient client) => ModelManager(
|
||||
baseUrl: 'http://test.local',
|
||||
client: client,
|
||||
rootDir: () async => root,
|
||||
);
|
||||
|
||||
MockClient client(List<Map<String, dynamic>> models) => MockClient((req) async {
|
||||
if (req.url.path == '/api/v1/app/update') {
|
||||
// Response(String) 默认 latin1 编码,中文数据集名会抛异常 → 用 bytes
|
||||
return http.Response.bytes(utf8.encode(jsonEncode(_catalog(models))), 200);
|
||||
}
|
||||
if (req.url.path.startsWith('/download/models/')) {
|
||||
downloadHits++;
|
||||
return http.Response.bytes(_modelBytes, 200);
|
||||
}
|
||||
return http.Response('not found', 404);
|
||||
});
|
||||
|
||||
test('首次拉取:下载模型并落盘(model/labels/meta)', () async {
|
||||
final m = manager(client([_item()]));
|
||||
await m.refresh();
|
||||
|
||||
expect(m.ready, isTrue);
|
||||
expect(m.error, isNull);
|
||||
expect(m.models.length, 1);
|
||||
expect(m.models.first.datasetName, '野鸡数据集');
|
||||
expect(m.models.first.bytes, _modelBytes);
|
||||
expect(downloadHits, 1);
|
||||
|
||||
final dir = Directory('${root.path}/7');
|
||||
expect(await File('${dir.path}/model.tflite').exists(), isTrue);
|
||||
expect(await File('${dir.path}/labels.json').exists(), isTrue);
|
||||
expect(await File('${dir.path}/meta.json').exists(), isTrue);
|
||||
});
|
||||
|
||||
test('版本未变不重复下载(meta 命中直接跳过)', () async {
|
||||
final m = manager(client([_item()]));
|
||||
await m.refresh();
|
||||
expect(downloadHits, 1);
|
||||
|
||||
await m.refresh();
|
||||
expect(downloadHits, 1, reason: 'meta 匹配应跳过下载');
|
||||
expect(m.models.length, 1);
|
||||
});
|
||||
|
||||
test('版本更新触发重新下载', () async {
|
||||
final m = manager(client([_item()]));
|
||||
await m.refresh();
|
||||
expect(downloadHits, 1);
|
||||
|
||||
await m.refresh();
|
||||
expect(downloadHits, 1);
|
||||
|
||||
// 发布新版本:再次刷新应重下
|
||||
await m.refresh();
|
||||
expect(downloadHits, 1);
|
||||
// 上面三次同一版本,重新构造带新版本的 manager(同一 root)
|
||||
final m2 = manager(client([_item(version: 'v2.0.0')]));
|
||||
await m2.refresh();
|
||||
expect(downloadHits, 2);
|
||||
expect(m2.models.first.version, 'v2.0.0');
|
||||
});
|
||||
|
||||
test('sha256 不匹配:重试后失败,保留旧模型并报错', () async {
|
||||
// 第一次下载成功(sha 匹配)
|
||||
final m1 = manager(client([_item()]));
|
||||
await m1.refresh();
|
||||
expect(m1.models.length, 1);
|
||||
|
||||
// 服务器 sha 与文件不符(被篡改/损坏)→ 下载校验失败
|
||||
final bad = _item(version: 'v3.0.0');
|
||||
bad['sha256'] = _shaHex([9, 9, 9]);
|
||||
final m2 = manager(client([bad]));
|
||||
await m2.refresh();
|
||||
|
||||
expect(m2.models.length, 0, reason: '校验失败的模型不应加载');
|
||||
expect(m2.error, isNotNull);
|
||||
expect(m2.error, contains('野鸡数据集'));
|
||||
});
|
||||
|
||||
test('目录下线:清理本地并清空模型', () async {
|
||||
final m = manager(client([_item()]));
|
||||
await m.refresh();
|
||||
expect(m.models.length, 1);
|
||||
expect(await Directory('${root.path}/7').exists(), isTrue);
|
||||
|
||||
final m2 = manager(client([]));
|
||||
await m2.refresh();
|
||||
expect(m2.models, isEmpty);
|
||||
expect(await Directory('${root.path}/7').exists(), isFalse,
|
||||
reason: '下线的数据集模型目录应被清理');
|
||||
});
|
||||
|
||||
test('目录接口异常:不覆盖已有就绪状态', () async {
|
||||
final m = manager(client([_item()]));
|
||||
await m.refresh();
|
||||
expect(m.ready, isTrue);
|
||||
|
||||
final broken = manager(MockClient((_) async => http.Response('boom', 500)));
|
||||
await broken.refresh();
|
||||
expect(broken.ready, isFalse);
|
||||
expect(broken.error, isNotNull);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import 'package:observer/detection/detection_result.dart';
|
||||
import 'package:observer/detection/detector_worker.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
DetectionResult box(String label, double score, double x, double y,
|
||||
{int modelId = -1, String modelName = ''}) =>
|
||||
DetectionResult(
|
||||
label: label,
|
||||
score: score,
|
||||
left: x,
|
||||
top: y,
|
||||
right: x + 0.1,
|
||||
bottom: y + 0.1,
|
||||
modelId: modelId,
|
||||
modelName: modelName,
|
||||
);
|
||||
|
||||
void main() {
|
||||
test('不同模型同标签重复框:NMS 去重取高分', () {
|
||||
// 野鸡模型与野兔模型都检出了同一只"野鸡"(不同模型对同一目标的重复框)
|
||||
final all = [
|
||||
box('pheasant', 0.18, 0.3, 0.3, modelId: 1, modelName: '野鸡模型'),
|
||||
box('pheasant', 0.55, 0.31, 0.3, modelId: 2, modelName: '野兔模型'),
|
||||
];
|
||||
final merged = mergeAcrossModels(all, 0.45);
|
||||
expect(merged.length, 1);
|
||||
expect(merged.first.score, 0.55);
|
||||
expect(merged.first.modelName, '野兔模型');
|
||||
});
|
||||
|
||||
test('不同类别互不压制', () {
|
||||
final all = [
|
||||
box('pheasant', 0.3, 0.5, 0.5, modelId: 1),
|
||||
box('hare', 0.7, 0.5, 0.5, modelId: 2), // 同位置但不同类别
|
||||
];
|
||||
final merged = mergeAcrossModels(all, 0.45);
|
||||
expect(merged.length, 2);
|
||||
});
|
||||
|
||||
test('同模型内部与跨模型合并一致:远处不重叠保留', () {
|
||||
final all = [
|
||||
box('pheasant', 0.2, 0.1, 0.1, modelId: 1, modelName: '野鸡模型'),
|
||||
box('pheasant', 0.3, 0.8, 0.8, modelId: 1, modelName: '野鸡模型'),
|
||||
];
|
||||
final merged = mergeAcrossModels(all, 0.45);
|
||||
expect(merged.length, 2);
|
||||
expect(merged.first.score, 0.3); // 按分排序
|
||||
});
|
||||
|
||||
test('单条结果原样返回', () {
|
||||
final single = [box('suspect', 0.11, 0.2, 0.2)];
|
||||
final merged = mergeAcrossModels(single, 0.45);
|
||||
expect(identical(merged, single), isTrue);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user