1
This commit is contained in:
@@ -120,10 +120,10 @@ class CameraViewModel extends ChangeNotifier {
|
||||
visible.add(r.copyWith(confirmed: t.confirmed));
|
||||
}
|
||||
|
||||
// 提醒:仅新确认的目标物种轨迹(class 0 即目标,label 由训练数据决定;
|
||||
// 确认瞬间触发一次,10s 同类冷却在 Reminder 内)
|
||||
// 提醒:仅新确认的目标物种轨迹(label 非 suspect 即目标——单物种模型
|
||||
// class 0、综合模型各物种索引 0..N-1;确认瞬间触发一次,10s 冷却在 Reminder 内)
|
||||
for (final t in _tracks.values) {
|
||||
final isSuspect = t.result.classId > 0 || t.label == 'suspect';
|
||||
final isSuspect = t.result.label == 'suspect';
|
||||
if (isSuspect || !t.confirmed || t.reminded) continue;
|
||||
final age = now - t.firstSeenMs;
|
||||
if (age >= displayAgeMs && age <= displayAgeMs + 1600 &&
|
||||
@@ -229,7 +229,7 @@ class CameraViewModel extends ChangeNotifier {
|
||||
/// - 目标物种:确认轨迹直接显示;未确认的只有在高分或活动证据时才显示
|
||||
bool _shouldDisplay(_Track t, List<MotionRegion> motionRegions,
|
||||
List<MotionRegion> noveltyRegions) {
|
||||
if (t.result.classId > 0 || t.label == 'suspect') return true;
|
||||
if (t.label == 'suspect') return true;
|
||||
if (t.confirmed) return true;
|
||||
return t.result.score >= highConf ||
|
||||
_hasActivity(t.result, motionRegions, noveltyRegions);
|
||||
@@ -248,7 +248,7 @@ class CameraViewModel extends ChangeNotifier {
|
||||
if (w <= 0 || h <= 0) return false;
|
||||
final aspect = w / h;
|
||||
if (aspect < 0.3 || aspect > 3.0) return false;
|
||||
if (r.classId > 0 || r.label == 'suspect') return h >= 0.01 && h <= 0.5;
|
||||
if (r.label == 'suspect') return h >= 0.01 && h <= 0.5;
|
||||
return h >= 0.01 && h <= 0.3;
|
||||
}
|
||||
|
||||
|
||||
@@ -72,9 +72,9 @@ class _OverlayPainter extends CustomPainter {
|
||||
size.width,
|
||||
size.height,
|
||||
);
|
||||
// 颜色按类别索引而非 label 文本:模型类别名随数据集变化(物种中文名等),
|
||||
// class 0 恒为目标物种(红),其余类恒为 suspect(黄)
|
||||
final isSuspect = r.classId > 0 || r.label == 'suspect';
|
||||
// 颜色按 label 文本而非类别索引(2026-09-09 综合模型多类:0..N-1 均为
|
||||
// 目标物种红框,仅类名 suspect 走生境预警黄框;单物种模型语义同前)
|
||||
final isSuspect = r.label == 'suspect';
|
||||
final color = isSuspect ? Color(0xFFFDD835) : Color(0xFFE53935);
|
||||
final confirmed = r.confirmed && !isSuspect;
|
||||
final paint = Paint()
|
||||
@@ -115,9 +115,7 @@ class _OverlayPainter extends CustomPainter {
|
||||
}
|
||||
|
||||
String _distanceLabel(DetectionResult r) {
|
||||
final refH = (r.classId > 0 || r.label == 'suspect')
|
||||
? _refSizeSuspectM
|
||||
: _refSizeSpeciesM;
|
||||
final refH = r.label == 'suspect' ? _refSizeSuspectM : _refSizeSpeciesM;
|
||||
final hPx = r.height * imageHeightPx;
|
||||
if (hPx < 8) return '';
|
||||
final m = focalPx * refH / hPx;
|
||||
|
||||
@@ -5,22 +5,21 @@ import '../models/model_manager.dart';
|
||||
/// 档位展示名(档位码 s/n 仅内部记账,不对用户展示)
|
||||
String _variantLabel(String v) => v == kVariantN ? '高性能' : '高精度';
|
||||
|
||||
/// 设置弹层「模型清单」区块:顶部「识别模式」分段控件(高性能 / 高精度,
|
||||
/// 默认高精度,持久化本地;2026-09-03 高性能移左位)选择**目标档位**——
|
||||
/// 只记录偏好,不直接切换运行中的模型,而是决定卡片按钮面向哪个档;下方
|
||||
/// 2 列封面缩略图网格,同一物种(datasetId)合并一张卡(s/n 两档内部记账,
|
||||
/// 各自下载独立进度)。
|
||||
///
|
||||
/// 同一物种一次只运行一个档位:启用某档会自动停用同物种另一档(不同物种可用
|
||||
/// 不同档位并行识别)。物种卡片主按钮(面向目标档,2026-09-03 简化文案——
|
||||
/// 一律「使用/使用中/下载」,不再叫「改用X」,不显示当前运行档提示):
|
||||
/// 设置弹层「模型清单」区块:顶部「识别档位」分段控件(高性能/高精度,默认
|
||||
/// 高精度,持久化本地)选择**目标档位**——只记录偏好,不直接切换运行中的
|
||||
/// 模型,决定卡片主按钮面向哪一档(2026-09-11 回归单按钮制:卡内不再同列
|
||||
/// 两档按钮)。多物种综合识别 App 端下线(2026-09-11):目录中 combined 条目
|
||||
/// 直接忽略、不展示,服务端/管理端综合训练功能保持不变。下方 2 列封面网格,
|
||||
/// 同一数据集合并一张卡(s/n 两档内部记账,各自下载独立进度),卡片只对
|
||||
/// **目标档**给一个主按钮:
|
||||
/// - 任一档下载中 → 逐档进度条 + 取消(中止全部进行中的下载);
|
||||
/// - 任一档失败 → 错误提示 + 重试(只补下未成功的档);
|
||||
/// - 目标档已激活 →「使用中」点击取消使用;
|
||||
/// - 目标档已下载未激活 →「使用」直接启用(另一档在运行会被自动停用);
|
||||
/// - 目标档未下载 →「下载」只取回目标档(2026-09-09 双档分别下载,伴档不随下);
|
||||
/// 另一档在使用时补下目标档后自动切换过去。切档/刷新检测目标档「未下载/
|
||||
/// 待更新」出横幅,用户手动一键下载/更新(自动更新已退场)。
|
||||
/// - 目标档已下载未激活 →「使用」直接启用(同数据集另一档在运行会自动停用);
|
||||
/// - 目标档未下载 →「下载」只取回目标档(双档分别下载,伴档不随下;另一档
|
||||
/// 正在运行而缺目标档时,下载完自动切换过去)。
|
||||
/// 另一档的版本/状态以灰字行展示,供对照。新版本由用户在卡片上手动重新
|
||||
/// 下载(使用中重下会原地生效,2026-09-09 自动更新/待办横幅已退场)。
|
||||
class ModelCatalogSection extends StatelessWidget {
|
||||
final ModelManager manager;
|
||||
|
||||
@@ -31,14 +30,16 @@ class ModelCatalogSection extends StatelessWidget {
|
||||
return ListenableBuilder(
|
||||
listenable: manager,
|
||||
builder: (context, _) {
|
||||
// 目录按数据集分组:同一物种 s/n 合成一张卡;组内 s(高精度)前 n 后
|
||||
// 目录按数据集分组:同一数据集 s/n 合成一张卡;组内 s(高精度)前 n 后。
|
||||
// 综合(combined,datasetId=0)条目 App 端忽略(2026-09-11 多物种下线)
|
||||
final byDataset = <int, List<ModelCatalogItem>>{};
|
||||
for (final c in manager.catalog) {
|
||||
if (c.isCombined) continue;
|
||||
byDataset.putIfAbsent(c.datasetId, () => []).add(c);
|
||||
}
|
||||
final groups = byDataset.values.toList()
|
||||
..sort((a, b) => a.first.datasetId.compareTo(b.first.datasetId));
|
||||
final order = {kVariantS: 0, kVariantN: 1};
|
||||
const order = {kVariantS: 0, kVariantN: 1};
|
||||
for (final g in groups) {
|
||||
g.sort((a, b) =>
|
||||
(order[a.variant] ?? 9).compareTo(order[b.variant] ?? 9));
|
||||
@@ -50,62 +51,26 @@ class ModelCatalogSection extends StatelessWidget {
|
||||
Row(
|
||||
children: [
|
||||
const Text(
|
||||
'识别模式',
|
||||
'识别档位',
|
||||
style: TextStyle(color: Colors.white70, fontSize: 14),
|
||||
),
|
||||
const Spacer(),
|
||||
_ModeToggle(manager: manager),
|
||||
_SegmentSwitch(
|
||||
selected: manager.mode,
|
||||
// 高性能在左、高精度在右(档位码仅内部记账)
|
||||
options: const [
|
||||
(value: kVariantN, label: '高性能'),
|
||||
(value: kVariantS, label: '高精度'),
|
||||
],
|
||||
onSelect: (v) => manager.setMode(v),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
const SizedBox(height: 6),
|
||||
const Text(
|
||||
'卡片操作面向所选档位;同一动物一次只运行一档,切换会自动停用另一档',
|
||||
'卡片操作面向所选档位;同一数据集一次只运行一档,切换会自动停用另一档',
|
||||
style: TextStyle(color: Colors.white38, fontSize: 11),
|
||||
),
|
||||
// 目标档待办横幅(切档检测):未下载/版本落后的模型,用户手动一键触发
|
||||
if (manager.modePending.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.system_update_alt,
|
||||
color: Colors.orangeAccent, size: 15),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${_variantLabel(manager.mode)}模式:'
|
||||
'${manager.modePending.length} 个模型待下载/更新',
|
||||
style: const TextStyle(
|
||||
color: Colors.orangeAccent, fontSize: 11.5),
|
||||
),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
for (final i in manager.modePending.toList()) {
|
||||
manager.downloadModel(i);
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.download, size: 14),
|
||||
label: const Text('一键下载',
|
||||
style: TextStyle(fontSize: 11.5)),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Colors.orangeAccent,
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||
minimumSize: const Size(0, 26),
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
@@ -164,15 +129,18 @@ class ModelCatalogSection extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// 目标档位分段控件:高性能 / 高精度(2026-09-03 调换两档显示位置,
|
||||
/// 高性能在左、高精度在右;档位码 s/n 仅内部使用,不对用户展示)
|
||||
class _ModeToggle extends StatelessWidget {
|
||||
final ModelManager manager;
|
||||
/// 通用分段开关:白底圆角外框,选中段绿色高亮;[options] = (值, 展示名) 列表,
|
||||
/// 点某段回调 [onSelect](识别档位行使用)
|
||||
class _SegmentSwitch extends StatelessWidget {
|
||||
final String selected;
|
||||
final List<({String value, String label})> options;
|
||||
final ValueChanged<String> onSelect;
|
||||
|
||||
const _ModeToggle({required this.manager});
|
||||
|
||||
/// 展示顺序(与内部档位常量解耦)
|
||||
static const List<String> _displayOrder = [kVariantN, kVariantS];
|
||||
const _SegmentSwitch({
|
||||
required this.selected,
|
||||
required this.options,
|
||||
required this.onSelect,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -185,32 +153,32 @@ class _ModeToggle extends StatelessWidget {
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (var i = 0; i < _displayOrder.length; i++) ...[
|
||||
for (var i = 0; i < options.length; i++) ...[
|
||||
if (i > 0) const SizedBox(width: 2),
|
||||
_seg(_displayOrder[i]),
|
||||
_seg(options[i]),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _seg(String v) {
|
||||
final selected = manager.mode == v;
|
||||
Widget _seg(({String value, String label}) o) {
|
||||
final sel = selected == o.value;
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
onTap: () => manager.setMode(v),
|
||||
onTap: () => onSelect(o.value),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? Colors.greenAccent : Colors.transparent,
|
||||
color: sel ? Colors.greenAccent : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
_variantLabel(v),
|
||||
o.label,
|
||||
style: TextStyle(
|
||||
color: selected ? Colors.black : Colors.white70,
|
||||
color: sel ? Colors.black : Colors.white70,
|
||||
fontSize: 12,
|
||||
fontWeight: selected ? FontWeight.bold : FontWeight.normal,
|
||||
fontWeight: sel ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -218,8 +186,8 @@ class _ModeToggle extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// 单物种卡片:同数据集 s/n 两档合并;下载/激活状态按 (数据集, 档位) 独立记账,
|
||||
/// 同物种同时至多一个档位被激活(激活以目标档为准)
|
||||
/// 单物种卡片:同数据集 s/n 两档合并展示但**只给目标档一个主按钮**;下载/激活
|
||||
/// 状态按 (数据集, 档位) 独立记账,同物种同时至多一个档位被激活(目标档为准)
|
||||
class _SpeciesCard extends StatelessWidget {
|
||||
final List<ModelCatalogItem> items;
|
||||
final ModelManager manager;
|
||||
@@ -311,7 +279,8 @@ class _SpeciesCard extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
// 双档行:高精度 / 高性能各自版本号;使用中绿色高亮、已下载次之、缺失置灰
|
||||
// 双档灰字状态行:高精度 / 高性能各自版本号;使用中绿色高亮、
|
||||
// 已下载次之、缺失置灰(纯信息,非按钮)
|
||||
Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
@@ -350,7 +319,7 @@ class _SpeciesCard extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// 动作区:进度/错误优先级最高;其次按目标档的下载/激活状态给主按钮
|
||||
/// 动作区:进度/错误优先级最高;其次按目标档的下载/激活状态给唯一主按钮
|
||||
Widget _actionArea({
|
||||
required List<ModelCatalogItem> items,
|
||||
required ModelCatalogItem target,
|
||||
@@ -443,8 +412,8 @@ class _SpeciesCard extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
} else if (tDownloaded) {
|
||||
// 已下载未激活(目标档):点「使用」直接启用——同物种另一档在使用会被
|
||||
// 自动停用(每数据集至多一档运行),统一文案不再叫「改用X」(2026-09-03)
|
||||
// 已下载未激活(目标档):点「使用」直接启用——同物种另一档在使用会
|
||||
// 自动停用(每数据集至多一档运行)
|
||||
mainBtn = SizedBox(
|
||||
height: 30,
|
||||
child: FilledButton(
|
||||
@@ -459,8 +428,8 @@ class _SpeciesCard extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// 目标档未下载:只下载目标档(2026-09-09 双档分别下载——伴档不随下,
|
||||
// 缺失/待更新由切档横幅提示手动补);另一档在使用时补下目标档后自动切换
|
||||
// 目标档未下载:只下载目标档(双档分别下载——伴档不随下,缺失档由
|
||||
// 切档后按钮补下);另一档在使用时补下目标档后自动切换过去
|
||||
final use = activeOther.isNotEmpty;
|
||||
mainBtn = SizedBox(
|
||||
height: 30,
|
||||
|
||||
@@ -9,7 +9,8 @@ class DetectionResult {
|
||||
/// 轨迹已确认(多帧稳定/高分/活动确认),false = 候选,渲染为虚线
|
||||
final bool confirmed;
|
||||
|
||||
/// 类别索引:0 = 目标物种(红色框),>0 = suspect(黄色框);-1 = 未知
|
||||
/// 类别索引:单物种模型 0 = 目标物种、1 = suspect;综合模型 0..N-1 = 各物种、
|
||||
/// 末位 = suspect。仅作展示索引,类别语义看 label 文本(2026-09-09 综合模型)
|
||||
final int classId;
|
||||
|
||||
/// 产出该框的模型(数据集 id 与名称;无来源为 -1/空)
|
||||
@@ -34,8 +35,10 @@ class DetectionResult {
|
||||
double get centerX => (left + right) / 2;
|
||||
double get centerY => (top + bottom) / 2;
|
||||
|
||||
/// 疑似(生境预警)类别:类别索引 >0 或训练标签为 suspect
|
||||
bool get isSuspect => classId > 0 || label == 'suspect';
|
||||
/// 疑似(生境预警)类别:训练标签为 suspect。单物种模型 suspect 在索引 1,
|
||||
/// 综合模型在末位——统一按标签名判定,不能按 classId>0(综合模型其他物种
|
||||
/// 索引也 >0,2026-09-09)
|
||||
bool get isSuspect => label == 'suspect';
|
||||
|
||||
DetectionResult copyWith({
|
||||
double? score,
|
||||
|
||||
@@ -40,8 +40,9 @@ class VisualPrior {
|
||||
if (results.isEmpty || width <= 0 || height <= 0) return results;
|
||||
final kept = <DetectionResult>[];
|
||||
for (final r in results) {
|
||||
// class 0 即目标物种(各数据集标签统一:0 目标 / 其余 suspect)
|
||||
final lowConfTarget = r.classId == 0 && r.score < maxScore;
|
||||
// 仅目标物种做视觉先验剔除(suspect=生境区域不受限;综合模型各物种
|
||||
// 索引 0..N-1 均目标,按 label 而非 classId 判定,2026-09-09)
|
||||
final lowConfTarget = !r.isSuspect && r.score < maxScore;
|
||||
if (lowConfTarget &&
|
||||
_reject(r, planes, strides, width, height, isBgra, rgbaOrder)) {
|
||||
continue;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
@@ -18,6 +17,10 @@ const String kVariantN = 'n';
|
||||
const String kKindSpecies = 'species';
|
||||
const String kKindCombined = 'combined';
|
||||
|
||||
/// 识别档位偏好取值 s/n 同 [kVariantS]/[kVariantN](2026-09-11 多物种综合识别
|
||||
/// 在 App 端下线:目录中 combined 条目直接忽略,历史 multi_* / 两维识别方式
|
||||
/// 配置自动归并为对应档位,不再有 multi_* 取值)
|
||||
|
||||
/// 模型身份键:同一数据集不同档位是两个独立条目(下载/激活/记账互不影响)
|
||||
typedef ModelKey = ({int datasetId, String variant});
|
||||
|
||||
@@ -101,19 +104,22 @@ class ModelBundle {
|
||||
/// 档位标识符的「无子目录」形态,存量设备无需迁移),n 档存 `models/<datasetId>/n/`;
|
||||
/// 各目录含 model.tflite + labels.json + meta.json,meta 记录 {version, sha256},
|
||||
/// 版本与摘要都未变化时跳过下载。记账键一律是 (datasetId, variant) 二元组。
|
||||
/// 识别目标档位 [mode] 只是用户偏好(持久化):设置弹层卡片按钮面向该档位。
|
||||
/// 识别档位 [mode] 只是用户偏好(持久化:s 高精度默认 / n 高性能,切换不直接
|
||||
/// 热换运行中模型):作为各卡默认目标档与「下载完成自动启用」的判定依据
|
||||
/// (2026-09-11 多物种综合识别 App 端下线——不再展示综合卡,目录中 combined
|
||||
/// 条目直接忽略,历史 multi_* / 两维识别方式配置自动归并到对应档位)。
|
||||
/// 实际运行由激活集驱动——每个数据集**至多一个档位**在使用:激活某档会自动停用
|
||||
/// 同数据集另一档,不同数据集可用不同档位并行识别(2026-09-03 修订)。
|
||||
/// 同数据集另一档,不同数据集可用不同档位并行识别(2026-09-03)。
|
||||
/// 激活集是**会话态**(2026-09-03 修订):每次进入视野页 [resetForSession] 清空、
|
||||
/// 不跨会话持久化——识别需用户在模型清单手动启用(显式「下载」落地即启用目标档
|
||||
/// 属于用户动作);上次崩溃/坏模型不会在下次打开时自动复现,用户总能看到仅预览
|
||||
/// 界面并自行调整。
|
||||
/// 目录**缓存优先**(2026-09-03):最近一次成功拉取的 models 目录落盘
|
||||
/// catalog.json,[refresh] 开头先载入缓存并通知(弹层离线也有内容展示),网络
|
||||
/// 成功后再以权威目录覆盖并落盘;清理/激活同步/待办检测只在网络成功(fetched)
|
||||
/// 后执行——缓存降级时不清文件不下载,离线首启不误删已下载模型。
|
||||
/// 版本更新**手动制**(2026-09-09):静默后台自动更新退场,切档/目录刷新仅
|
||||
/// 检测目标档「未下载/待更新」置入 [modePending],由用户在横幅手动一键触发。
|
||||
/// 成功后再以权威目录覆盖并落盘;清理/激活同步只在网络成功(fetched)后执行
|
||||
/// ——缓存降级时不清文件不下载,离线首启不误删已下载模型。
|
||||
/// 模型更新**手动制**(2026-09-09):无自动更新/待办横幅,新版本由用户在
|
||||
/// 卡片上重新下载(使用中重下会原地生效)。
|
||||
class ModelManager extends ChangeNotifier {
|
||||
static final ModelManager instance = ModelManager._();
|
||||
|
||||
@@ -135,10 +141,6 @@ class ModelManager extends ChangeNotifier {
|
||||
String? _error;
|
||||
Future<void>? _inFlight;
|
||||
|
||||
/// 目标档待办清单(2026-09-09):当前 [mode] 下「未下载」或「本地版本落后
|
||||
/// 服务器」的模型条目,切档/目录刷新时重算,UI 出横幅由用户手动一键下载/更新
|
||||
List<ModelCatalogItem> _modePending = const [];
|
||||
|
||||
/// 模型文件/激活集变更版本戳:下载完成或激活变化 +1,
|
||||
/// UI 据此判断是否需要重建推理 worker(2026-09-01 自动更新引入)
|
||||
int _revision = 0;
|
||||
@@ -147,13 +149,10 @@ class ModelManager extends ChangeNotifier {
|
||||
/// 服务器目录(弹层模型清单展示用;同一数据集可能 s/n 两行)
|
||||
List<ModelCatalogItem> get catalog => _catalog;
|
||||
|
||||
/// 识别目标档位(默认 s 高精度):用户偏好,持久化;只决定卡片按钮与
|
||||
/// 首次下载的自动激活档,不直接切换已在运行的模型(运行看激活集)
|
||||
/// 识别档位偏好(s 高精度 / n 高性能):作为各卡默认目标档
|
||||
/// (不直接切换运行——运行由激活集驱动)
|
||||
String get mode => _mode;
|
||||
|
||||
/// 目标档待办清单(未下载 / 本地版本落后),切档时检测、UI 出横幅手动触发
|
||||
List<ModelCatalogItem> get modePending => _modePending;
|
||||
|
||||
bool isActive(int datasetId, String variant) =>
|
||||
_active.contains((datasetId: datasetId, variant: variant));
|
||||
|
||||
@@ -205,33 +204,15 @@ class ModelManager extends ChangeNotifier {
|
||||
return _models.map((m) => m.datasetName).join(',');
|
||||
}
|
||||
|
||||
/// 切换识别目标档位:改偏好并持久化,同时**检测目标档待办**(未下载 /
|
||||
/// 版本落后),结果置入 [modePending] 供 UI 横幅手动触发下载/更新
|
||||
Future<void> setMode(String variant) async {
|
||||
if (variant != kVariantS && variant != kVariantN) return;
|
||||
if (_mode == variant) return;
|
||||
_mode = variant;
|
||||
/// 切换识别档位偏好(s 高精度 / n 高性能):只改默认目标档并持久化。
|
||||
Future<void> setMode(String mode) async {
|
||||
if (mode != kVariantS && mode != kVariantN) return;
|
||||
if (_mode == mode) return;
|
||||
_mode = mode;
|
||||
await _saveMode();
|
||||
await refreshModePending();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 重算目标档待办:未下载(无文件)或本地版本/sha 与服务器不符(有新版本)。
|
||||
/// 下载中的条目跳过(落地后自然移出)
|
||||
Future<void> refreshModePending() async {
|
||||
final out = <ModelCatalogItem>[];
|
||||
for (final item in _catalog.where((c) => c.variant == _mode)) {
|
||||
final key = (datasetId: item.datasetId, variant: item.variant);
|
||||
if (_progress.containsKey(key)) continue;
|
||||
if (!await _hasFile(item)) {
|
||||
out.add(item); // 未下载
|
||||
continue;
|
||||
}
|
||||
if (!await _isLocal(item)) out.add(item); // 有新版本
|
||||
}
|
||||
_modePending = out;
|
||||
}
|
||||
|
||||
/// 拉取目录并同步本地模型;并发调用共享同一进行中的刷新。
|
||||
Future<void> refresh() {
|
||||
if (_refreshing) return _inFlight ?? Future.value();
|
||||
@@ -287,9 +268,9 @@ class ModelManager extends ChangeNotifier {
|
||||
// 无缓存且未拉取成功(目录确为空):无从同步,等下次刷新
|
||||
if (_catalog.isEmpty && !fetched) return;
|
||||
|
||||
// 只拉目录不下载;扫描本地已有模型文件供清单展示。版本新旧都算已下载:
|
||||
// 旧版本文件由 autoUpdate 静默补齐,无需用户看到「下载」按钮再下。
|
||||
// 缓存目录同样扫描:离线重开也能正确标出已下载档位
|
||||
// 只拉目录不下载;扫描本地已有模型文件供清单展示(版本是否落后由
|
||||
// 卡片对照目录版本提示「更新」)。缓存目录同样扫描:离线重开也能正确
|
||||
// 标出已下载档位
|
||||
final downloaded = <ModelKey>{};
|
||||
for (final item in _catalog) {
|
||||
if (await _hasFile(item)) {
|
||||
@@ -300,8 +281,8 @@ class ModelManager extends ChangeNotifier {
|
||||
..clear()
|
||||
..addAll(downloaded);
|
||||
|
||||
// 清理/激活同步/自动更新只认网络拉到的权威目录:缓存降级时不清文件、
|
||||
// 不触发下载——离线首启不会误删已下载模型(2026-09-03)
|
||||
// 清理/激活同步只认网络拉到的权威目录:缓存降级时不清文件——
|
||||
// 离线首启不会误删已下载模型(2026-09-03)
|
||||
if (!fetched) return;
|
||||
await _prune(_catalog);
|
||||
// 服务器已下线的 (数据集, 档位) 移出激活集
|
||||
@@ -313,34 +294,15 @@ class ModelManager extends ChangeNotifier {
|
||||
_models = await _loadBundles(_catalog);
|
||||
_ready = true;
|
||||
_error = null;
|
||||
// 目标档待办重算(2026-09-09:自动更新退场,改为切档检测 + 手动触发——
|
||||
// 静默后台下载不区分档位流量开销大,且会掩盖「待更新」状态)
|
||||
await refreshModePending();
|
||||
} catch (e) {
|
||||
if (!_ready) _error = '模型目录拉取失败:$e';
|
||||
// 已就绪过则保留旧目录/旧模型,不覆盖 error(下载级错误优先展示)
|
||||
}
|
||||
}
|
||||
|
||||
/// 本地是否已有匹配版本的文件(meta 版本+sha256 相符且文件存在)
|
||||
Future<bool> _isLocal(ModelCatalogItem item) async {
|
||||
final dir = await _modelDir(item.datasetId, item.variant);
|
||||
try {
|
||||
final meta = await _readMeta(dir);
|
||||
final file = File('${dir.path}/model.tflite');
|
||||
return meta != null &&
|
||||
meta['version'] == item.version &&
|
||||
meta['sha256'] == item.sha256 &&
|
||||
await file.exists();
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 按需下载:流式下载 + sha256 校验 + 落盘(labels/meta)。
|
||||
/// [autoActivate](默认 true,用户显式下载)该数据集此前无任何档位在使用且
|
||||
/// 本档为目录中唯一可选/匹配目标档时自动激活(下载即有识别);
|
||||
/// autoUpdate 等后台补档传 false:只更新文件不改变激活状态。
|
||||
/// 使用中的档位原地更新则字节生效(重建推理 worker)。失败重试一次并记录错误。
|
||||
Future<bool> downloadModel(ModelCatalogItem item,
|
||||
{bool autoActivate = true,
|
||||
@@ -372,17 +334,15 @@ class ModelManager extends ChangeNotifier {
|
||||
_models = await _loadBundles(_catalog);
|
||||
} else if (autoActivate &&
|
||||
!_active.any((k) => k.datasetId == item.datasetId)) {
|
||||
// 用户显式下载且该数据集尚无档位在使用:自动激活目标档条目;
|
||||
// 目录没有目标档(存量单档物种)时激活本条,保证下载即有识别
|
||||
// 用户显式下载且该数据集尚无档位在使用:自动激活识别档位默认档
|
||||
// 条目;目录没有默认档(存量单档物种)时激活本条,保证下载即有
|
||||
// 识别。走 setActive 统一做同档覆盖互斥(同数据集至多一档运行)
|
||||
final hasTarget = _catalog.any((c) =>
|
||||
c.datasetId == item.datasetId && c.variant == _mode);
|
||||
if (item.variant == _mode || !hasTarget) {
|
||||
_active.add(key);
|
||||
_revision++;
|
||||
_models = await _loadBundles(_catalog);
|
||||
c.datasetId == item.datasetId && c.variant == mode);
|
||||
if (item.variant == mode || !hasTarget) {
|
||||
await setActive(item.datasetId, item.variant, true);
|
||||
}
|
||||
}
|
||||
unawaited(refreshModePending()); // 落地后移出待办横幅
|
||||
notifyListeners();
|
||||
return true;
|
||||
}
|
||||
@@ -576,7 +536,7 @@ class ModelManager extends ChangeNotifier {
|
||||
await File('${root.path}/mode.json')
|
||||
.writeAsString(jsonEncode({'mode': _mode}));
|
||||
} catch (e) {
|
||||
debugPrint('[ModelManager] 识别档位持久化失败: $e');
|
||||
debugPrint('[ModelManager] 识别偏好持久化失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -588,10 +548,20 @@ class ModelManager extends ChangeNotifier {
|
||||
final f = File('${root.path}/mode.json');
|
||||
if (!await f.exists()) return;
|
||||
final data = jsonDecode(await f.readAsString()) as Map<String, dynamic>;
|
||||
final m = data['mode'] as String? ?? kVariantS;
|
||||
if (m == kVariantS || m == kVariantN) _mode = m;
|
||||
final r = data['recognition'] as String?;
|
||||
if (r != null) {
|
||||
// 四选一时期(2026-09-11 定稿后、多物种下线前)的配置:后缀即档位
|
||||
if (r.endsWith(kVariantN)) {
|
||||
_mode = kVariantN;
|
||||
} else if (r.endsWith(kVariantS)) {
|
||||
_mode = kVariantS;
|
||||
}
|
||||
return;
|
||||
}
|
||||
// 更早 {mode 档位, source 单/多源} 两维结构:只看档位(multi 已下线)
|
||||
if (data['mode'] == kVariantN) _mode = kVariantN;
|
||||
} catch (e) {
|
||||
debugPrint('[ModelManager] 识别档位读取失败: $e');
|
||||
debugPrint('[ModelManager] 识别偏好读取失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -659,16 +629,6 @@ class ModelManager extends ChangeNotifier {
|
||||
return bundles;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>?> _readMeta(Directory dir) async {
|
||||
final f = File('${dir.path}/meta.json');
|
||||
if (!await f.exists()) return null;
|
||||
try {
|
||||
return jsonDecode(await f.readAsString()) as Map<String, dynamic>;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Directory> _rootDir() async {
|
||||
if (_rootDirOverride != null) return _rootDirOverride();
|
||||
final support = await getApplicationSupportDirectory();
|
||||
@@ -688,8 +648,8 @@ class ModelManager extends ChangeNotifier {
|
||||
return dir;
|
||||
}
|
||||
|
||||
/// 目录条目对应的模型文件是否已存在本地(不校验版本:旧版本视为已下载,
|
||||
/// 新版本由 autoUpdate 自动补齐)
|
||||
/// 目录条目对应的模型文件是否已存在本地(不校验版本:文件已存在即标已下载,
|
||||
/// 是否落后由卡片端对照目录版本提示「更新」)
|
||||
Future<bool> _hasFile(ModelCatalogItem item) async {
|
||||
final root = await _rootDir();
|
||||
return File('${root.path}/${_subPath(item.datasetId, item.variant)}/model.tflite')
|
||||
|
||||
@@ -2,7 +2,7 @@ name: observer
|
||||
description: "视野 - 动物实时识别 (环颈雉鸡/生境), YOLOv8 + 充值付费"
|
||||
publish_to: 'none'
|
||||
|
||||
version: 1.0.35+40
|
||||
version: 1.0.39+44
|
||||
|
||||
environment:
|
||||
sdk: ^3.12.2
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@
|
||||
| 后台管理端 | `server_admin/`(Vue3 + Element Plus)管理页面:订单查询、账号/授权管理(手动授权/撤销)、App 版本管理;构建产物由后端 `/admin/` 托管,登录页输入 token 后以 `X-Admin-Token` 头鉴权(`config.yml admin.token`) |
|
||||
| 版本管理 | 后台管理端上传 Android APK + 更新说明,APK 存服务器 `app.apkDir`(默认 `./workspace/`,与 `./data` 平级、挂载持久化)**固定文件名 `observer-latest.apk`,上传即覆盖,目录永远只保留最新一个文件**;**版本号从文件名识别**:文件须命名为 `observer-x.y.z.apk`(Flutter 打包产物即此命名,版本号取自 pubspec);客户端启动时 `GET /api/v1/app/update` 检查更新:服务器版本高于本地版本即弹更新提示(不可跳过)。**仅 Android 检查,iOS 不做版本下发**(iOS 走 App Store 自行更新)。版本记录可删除:删最新版本联动删除 APK 文件,删历史版本仅删记录 |
|
||||
| 数据训练(唯一入口) | 后台管理端「数据训练」一个菜单承载数据集全流程,**双 tab(2026-09-07)**:「数据集」tab = 数据集卡片列表(封面图/描述/图片数/已标注数/**训练状态徽标**),「负样本」tab = 负样本库图片网格(上传/删除,见技术设计.md「负样本库」——训练打包时统一混入全部物种数据集);**卡片下方直接展示训练任务进度条与状态**(无独立训练页);详情页为**图片与标注一体视图**:分页(每页 20 条)逐行「原图 ‖ 标注图」对照展示;**图片不自动标注(2026-09-04 自动标注退场)**:标注唯一入口 = 勾选图片顶栏「预标」(RF-DETR 四级漏斗检测,见技术设计.md「预标注四级漏斗」),进度条展示在页顶;**预标完成进「待审核」,人工审核通过才「已标注」**(`dataset_image.review_status` 0 未标注/1 待审核/2 已审核 三态,训练集只收已审核图);点击原图/标注图弹窗放大进入标注编辑器(画框/确认/清理,保存即视为已审核);封面(上传/生成统一 1248x704 转 jpg + UUID 命名)/**描述**/AI 生成图片(provider 抽象:dashscope 通义万相付费 API / localai 训练机 local-ai qwen-image,`config.yml imageGen` 节点切换,见配置说明);AI 标注端点与训练机 SSH 为**全局配置,直接读 `config.yml`**(`localAi` / `training.ssh` 节点,改配置需重启服务);图片落服务器 `app.datasetDir`/`datasets/<数据集名>/`,DB 存元数据 + 标注 JSON;**数据清洗(2026-09-02)**:详情页「数据清洗」——按标注目标尺寸细档统计超配,超配桶内整图 dHash 多样性保留、其余进候选清单,执行=打「已排除训练集」标记(可恢复不删图),prepare_yolo 打包跳过 |
|
||||
| 模型训练 | 从数据集卡片「开始训练」一键触发(参数 imgsz/epochs/batch/device 默认走 `config.yml` `training` 节点,部署级配置);**双档位(2026-09-03)**:一次发起按档位各建一条任务——高识别档 s(基座 `training.model`、imgsz `training.imgsz`=1280)/ 高性能档 n(基座 `training.modelN`=yolov8n.pt、imgsz `training.imgszN`=704),请求传 `variants:["s","n"]` 限定(省略=双档;n 档配置缺失时请求报错),epochs/batch/device 双档共用,任务带 `variant` 快照;**综合训练(2026-09-09)**:`POST /admin/trainings/combined` 勾选 ≥2 个数据集 + 档位,多物种合并训练出**一个综合模型**(全类一张 tflite:类别表 = 各物种名按数据集 id 升序 + 共享 suspect 置末位,打包时类别 id 重映射、负样本只混一份、图片名加 d<id>_ 前缀防跨数据集重名),产物/发布/目录下发走现有链路,文件基名 `combined`(combined.tflite / combined_n.tflite);单物种训练流程不变,两种模式并存(详见技术设计.md「综合训练」);**GPU 独占排队(2026-09-03)**:并发度 1 不变——已有 running 时新任务落 `queued` 排队(不再拒绝),10s 轮询在 running 结束后自动按创建顺序晋级启动、一次一个(训练机单 GPU 串行跑多档/多数据集),取消 running=杀进程、queued=直接置失败;进度/日志/指标监控(每 epoch 粒度);训练通道 `training` 节点可配置 subprocess(与 Go 服务同机直接起 python)/ ssh(异机执行,SSH 凭据取 `config.yml` `training.ssh` 节点);训练脚本 `server/training/train_server.py`(随项目迁移,2026-08-26)参数化(task.json 传 model/imgsz),产物(best.tflite/best.pt/曲线)拉回服务器;训练收尾自动做 **tflite 产物自检**(输入/输出 shape 校验,原 `inspect_tflite.py` 逻辑内嵌脚本),自检失败任务置失败并带出原因;`dump_graph.py` 留作训练机人工深度调试 |
|
||||
| 模型训练 | 从数据集卡片「开始训练」一键触发(参数 imgsz/epochs/batch/device 默认走 `config.yml` `training` 节点,部署级配置);**双档位(2026-09-03)**:一次发起按档位各建一条任务——高识别档 s(基座 `training.model`、imgsz `training.imgsz`=1280)/ 高性能档 n(基座 `training.modelN`=yolov8n.pt、imgsz `training.imgszN`=704),请求传 `variants:["s","n"]` 限定(省略=双档;n 档配置缺失时请求报错),epochs/batch/device 双档共用,任务带 `variant` 快照;**综合训练(2026-09-09)**:`POST /admin/trainings/combined` 勾选 ≥2 个数据集 + 档位,多物种合并训练出**一个综合模型**(全类一张 tflite:类别表 = 各物种名按数据集 id 升序 + 共享 suspect 置末位,打包时类别 id 重映射、负样本只混一份、图片名加 d<id>_ 前缀防跨数据集重名),产物/发布/目录下发走现有链路,文件基名 `combined`(combined.tflite / combined_n.tflite);单物种训练流程不变,两种模式并存(详见技术设计.md「综合训练」);**GPU 独占排队(2026-09-03)**:并发度 1 不变——已有 running 时新任务落 `queued` 排队(不再拒绝),10s 轮询在 running 结束后自动按创建顺序晋级启动、一次一个(训练机单 GPU 串行跑多档/多数据集),取消 running=杀进程、queued=直接置失败;进度/日志/指标监控(每 epoch 粒度);训练通道 `training` 节点可配置 subprocess(与 Go 服务同机直接起 python)/ ssh(异机执行,SSH 凭据取 `config.yml` `training.ssh` 节点);训练脚本 `server/training/train_server.py`(随项目迁移,2026-08-26)参数化(task.json 传 model/imgsz),产物(best.tflite/best.pt/曲线)拉回服务器;训练收尾自动做 **tflite 产物自检**(输入/输出 shape 校验,原 `inspect_tflite.py` 逻辑内嵌脚本),自检失败任务置失败并带出原因;**增量训练(2026-09-09)**:单物种/综合任务按档位 lineage 自动热启动——上一次成功的 best.pt 存档于 `workspace/trainings/weights/<基名>.pt`(基名同 tflite:单物种 `<前缀或数据集名>[_n]`、综合 `combined[_n]`),下次训练存在即推训练机作基座、不存在回落 config 基座(首次全量),类别数变化自动重建检测头;删该文件即从零重训;`dump_graph.py` 留作训练机人工深度调试 |
|
||||
| 模型版本与热更新 | **每数据集每档位一个模型**(2026-09-03 双档位):训练成功后一键「发布」(训练任务操作列)——tflite 已由训练成功直写最终位置:s 档 `workspace/trainings/<文件名前缀>.tflite`、n 档 `<文件名前缀>_n.tflite`(前缀空回退数据集名),发布仅落 `model_version` 记录(sha256/大小/指标/类别名,带 `variant` 档位列);版本序列每数据集全局共用 m1.0.0 递增(s/n 交替发布走同一序列,无档位独立序列),`is_latest` 按 (数据集, 档位) 各记一条——发布只清同档位旧记录,s/n 两档互不影响,目录可分别发布、分别下发。管理端**无模型管理界面**(版本记录仅支撑客户端下发)。**App 模型热更新**:`GET /api/v1/app/update` 扩展返回 `models` 目录数组,客户端独立检查,新模型下载校验替换,失败回退旧模型——模型迭代不再重打包 APK |
|
||||
| 模型目录与多模型推理 | `GET /api/v1/models`(登录态)返回全部数据集当前生效模型(数据集/档位 `variant` s|n/版本/类别/大小/sha256/下载地址;**每数据集最多 2 条 = s/n 两档各自的 is_latest**),下载 URL s 档 `/download/trainings/<文件名前缀>.tflite`、n 档 `/download/trainings/<文件名前缀>_n.tflite`(前缀空回退数据集名);**App 模型管理页**用户自由下载/删除/启用模型,识别时**按当前识别档位(s 高识别 / n 高性能,全局切换)加载该档位已启用模型**并行推理 + 跨模型 NMS 合并(按类别名),内置 assets 模型兜底 |
|
||||
| 标注 | **无自动标注(2026-09-04 退场,用户定案)**:上传/生成入库不触发任何检测,`localAi` 未配置不再阻断入库;标注唯一入口 = 管理端勾选图片顶栏「预标」→ `POST /admin/label-tasks`(RF-DETR **四级漏斗**:全图扫描→空检自动升级切片扫描→仍空 VLM 提议候选区+RF-DETR 精修;切片参数走 `localAi.tileSize`/`tileOverlap`/`tileThreshold`,见技术设计.md「预标注四级漏斗」;扫描结果 minIoU 重叠去重后直写 `dataset_image.labels_json`,空检出写 `[]` 且 review_status 保持未标注);**预标完成 →「待审核」(review_status=1),人工审核通过才「已标注」(=2)**,训练集打包只收已审核图(prepareYoloSet 质量闸门);工作台弹窗人工画框/确认后保存即视为已审核;管理端对待审核图批量「通过/拒绝」(拒绝 = 清标注回未标注池,并计入对应 App 用户的低质统计,见「标注众包赚时长」) |
|
||||
|
||||
@@ -170,6 +170,7 @@ func (s *trainingService) finishSuccess(ctx context.Context, runner common.Train
|
||||
Metrics map[string]float64 `json:"metrics"`
|
||||
Names []string `json:"names"`
|
||||
BestTflite string `json:"best_tflite"`
|
||||
BestPt string `json:"best_pt"`
|
||||
TfliteCheck *struct {
|
||||
OK bool `json:"ok"`
|
||||
Reason string `json:"reason"`
|
||||
@@ -204,6 +205,14 @@ func (s *trainingService) finishSuccess(ctx context.Context, runner common.Train
|
||||
_ = s.finishFailed(ctx, t, "拉取训练产物失败: %v", err)
|
||||
return
|
||||
}
|
||||
// 增量权重存档(2026-09-09):best.pt 回写 trainings/weights/<基名>.pt 供下次热启动;
|
||||
// 拉取失败仅记日志不置失败——tflite 才是服务产物,权重缺档下次自动回落全量基座
|
||||
if res.BestPt != "" {
|
||||
if err := runner.FetchArtifact(ctx, job, res.BestPt,
|
||||
common.TrainingWeightsPath(ctx, base)); err != nil {
|
||||
g.Log().Errorf(ctx, "训练 %d 存档增量权重失败(下次回落全量基座): %+v", t.Id, err)
|
||||
}
|
||||
}
|
||||
// 指标尾部带上类别名,发布时解析 labels
|
||||
if len(res.Names) > 0 {
|
||||
if names, err := json.Marshal(res.Names); err == nil {
|
||||
@@ -237,24 +246,29 @@ func (s *trainingService) finishFailed(ctx context.Context, t *entity.ModelTrain
|
||||
return dao.Training.Finish(ctx, t.Id, consts.TrainingStatusFailed, "", "", msg)
|
||||
}
|
||||
|
||||
// buildJob 组装训练机路径布局的 runner 任务
|
||||
// buildJob 组装训练机路径布局的 runner 任务。
|
||||
// 综合任务(kind=combined)数据集 id=0:训练机目录/文件基名 fixed combined,不走数据集表
|
||||
// (prepareAndLaunch 晋级时同一定义;轮询重建沿用,否则 pollOne 永不触达综合任务)
|
||||
func (s *trainingService) buildJob(ctx context.Context, t *entity.ModelTraining, cfg common.TrainingConfig) (*common.TrainingJob, error) {
|
||||
dataset, err := dao.Dataset.GetById(ctx, t.DatasetId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
jobDsName := consts.TrainingCombinedBase
|
||||
if t.Kind != consts.TrainingKindCombined {
|
||||
dataset, err := dao.Dataset.GetById(ctx, t.DatasetId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if dataset == nil {
|
||||
return nil, gerror.NewCode(common.CodeDatasetNotFound)
|
||||
}
|
||||
jobDsName = dataset.Name
|
||||
}
|
||||
if dataset == nil {
|
||||
return nil, gerror.NewCode(common.CodeDatasetNotFound)
|
||||
}
|
||||
job := &common.TrainingJob{
|
||||
return &common.TrainingJob{
|
||||
TaskId: t.Id,
|
||||
DatasetName: dataset.Name,
|
||||
DatasetName: jobDsName,
|
||||
Python: cfg.Python,
|
||||
Workdir: cfg.Workdir,
|
||||
DatasetDir: cfg.DatasetDir,
|
||||
Pid: t.Pid,
|
||||
}
|
||||
return job, nil
|
||||
}, nil
|
||||
}
|
||||
|
||||
// trainingEtaMinutes 预计剩余时长(分钟):running 且已完成 ≥1 轮时按「已用均值 × 剩余轮数」估算
|
||||
@@ -683,6 +697,7 @@ func (s *trainingService) prepareAndLaunch(ctx context.Context, taskId int64) {
|
||||
var pkg *common.YoloPackage
|
||||
var classNames []string
|
||||
var jobDsName string
|
||||
var fileBase string // 产物文件基名(tflite 与增量权重共用,n 档 _n 后缀)
|
||||
if t.Kind == consts.TrainingKindCombined {
|
||||
ids, err := parseCombinedIds(t.DatasetIds)
|
||||
if err != nil {
|
||||
@@ -694,6 +709,10 @@ func (s *trainingService) prepareAndLaunch(ctx context.Context, taskId int64) {
|
||||
_ = s.finishFailed(ctx, t, "%s", err.Error())
|
||||
return
|
||||
}
|
||||
fileBase = consts.TrainingCombinedBase
|
||||
if t.Variant == consts.TrainingVariantN {
|
||||
fileBase += consts.TrainingVariantNFileSuffix
|
||||
}
|
||||
pkg, classNames, jobDsName = p, cls, consts.TrainingCombinedBase
|
||||
} else {
|
||||
dataset, err := dao.Dataset.GetById(ctx, t.DatasetId)
|
||||
@@ -713,6 +732,7 @@ func (s *trainingService) prepareAndLaunch(ctx context.Context, taskId int64) {
|
||||
pkg = p
|
||||
classNames = localAiClassNames(dataset)
|
||||
jobDsName = dataset.Name
|
||||
fileBase = modelFileBaseName(dataset.Name, dataset.NamePrefix, t.Variant)
|
||||
}
|
||||
job := &common.TrainingJob{
|
||||
TaskId: t.Id,
|
||||
@@ -726,6 +746,19 @@ func (s *trainingService) prepareAndLaunch(ctx context.Context, taskId int64) {
|
||||
if t.Variant == consts.TrainingVariantN {
|
||||
model = cfg.ModelN
|
||||
}
|
||||
// 增量基座(2026-09-09):上次成功权重存档(trainings/weights/<基名>.pt)存在则推训练机
|
||||
// 热启动;推送失败同样回落全量基座(训练照跑,日志可查)。删除存档文件即从零重训
|
||||
if fileBase != "" {
|
||||
weightLocal := common.TrainingWeightsPath(ctx, fileBase)
|
||||
if _, err := os.Stat(weightLocal); err == nil {
|
||||
remoteRel := filepath.ToSlash(filepath.Join("trainings", "weights", fileBase+".pt"))
|
||||
if err := runner.PushArtifact(ctx, job, remoteRel, weightLocal); err != nil {
|
||||
g.Log().Errorf(ctx, "训练 %d 推送增量权重失败,回落全量基座: %+v", t.Id, err)
|
||||
} else {
|
||||
model = remoteRel
|
||||
}
|
||||
}
|
||||
}
|
||||
// data.yaml 的 path 指向训练机路径,随包一起同步
|
||||
trainPath := filepath.Join(cfg.Workdir, cfg.DatasetDir, "yolo", jobDsName)
|
||||
pkg.Files = append(pkg.Files, common.YoloFile{
|
||||
|
||||
@@ -39,6 +39,9 @@ type TrainingRunner interface {
|
||||
FetchResult(ctx context.Context, job *TrainingJob) (string, error)
|
||||
// FetchArtifact 把训练机产物文件拉回服务器本地路径
|
||||
FetchArtifact(ctx context.Context, job *TrainingJob, remoteName, localPath string) error
|
||||
// PushArtifact 把服务器本地文件推到训练机(增量训练基座权重,2026-09-09;
|
||||
// remoteName 相对训练机 workdir)
|
||||
PushArtifact(ctx context.Context, job *TrainingJob, remoteName, localPath string) error
|
||||
// SyncYoloDataset 把训练集包落到训练机(subprocess 直写 workdir;ssh 走 tar 流式管道,本地不落盘)
|
||||
SyncYoloDataset(ctx context.Context, job *TrainingJob, pkg *YoloPackage) error
|
||||
// WriteTaskJson 把任务参数文件写到训练机(随 Start 前的准备阶段调用)
|
||||
@@ -222,6 +225,15 @@ func (r *subprocessRunner) FetchArtifact(ctx context.Context, job *TrainingJob,
|
||||
return WriteFileAtomic(localPath, data)
|
||||
}
|
||||
|
||||
func (r *subprocessRunner) PushArtifact(ctx context.Context, job *TrainingJob, remoteName, localPath string) error {
|
||||
data, err := os.ReadFile(localPath)
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "读取待推送文件失败")
|
||||
}
|
||||
dst := filepath.Join(job.Workdir, filepath.FromSlash(remoteName))
|
||||
return WriteFileAtomic(dst, data)
|
||||
}
|
||||
|
||||
func (r *subprocessRunner) SyncYoloDataset(ctx context.Context, job *TrainingJob, pkg *YoloPackage) error {
|
||||
// 同机训练:训练进程直接读 workdir 下文件,包直写目标目录(无中间暂存)
|
||||
dst := filepath.Join(job.Workdir, job.DatasetDir, "yolo", job.DatasetName)
|
||||
@@ -395,6 +407,28 @@ func (r *sshRunner) FetchArtifact(ctx context.Context, job *TrainingJob, remoteN
|
||||
return WriteFileAtomic(localPath, data)
|
||||
}
|
||||
|
||||
func (r *sshRunner) PushArtifact(ctx context.Context, job *TrainingJob, remoteName, localPath string) error {
|
||||
client, err := r.dial(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = client.Close() }()
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = session.Close() }()
|
||||
dst := filepath.Join(job.Workdir, filepath.FromSlash(remoteName))
|
||||
cmd := fmt.Sprintf("mkdir -p %s && cat > %s", filepath.Dir(dst), dst)
|
||||
f, err := os.Open(localPath)
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "读取待推送文件失败")
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
session.Stdin = f
|
||||
return session.Run(cmd)
|
||||
}
|
||||
|
||||
func (r *sshRunner) SyncYoloDataset(ctx context.Context, job *TrainingJob, pkg *YoloPackage) error {
|
||||
// tar 流式管道:原图直接读数据集目录打包,stdin 推远端解包,本地不落盘
|
||||
client, err := r.dial(ctx)
|
||||
|
||||
@@ -14,6 +14,9 @@ import (
|
||||
// datasets/<name>/ 数据集图片(平铺,文件名唯一,标注存 DB dataset_image.labels_json)
|
||||
// trainings/<model>.tflite 某数据集当前生效模型(训练成功即直写,客户端固定下载,无存档回退;
|
||||
// <model> = 数据集文件名前缀 name_prefix,空则回退数据集名,2026-08-28)
|
||||
// trainings/weights/<基名>.pt 增量训练基座权重存档(2026-09-09:基名同 tflite——单物种
|
||||
// <前缀或数据集名>[_n]、综合 combined[_n];下次训练推训练机热启动,
|
||||
// 删除即回落全量基座)
|
||||
//
|
||||
// 训练机与 Go 服务器异机时,数据集经 training 通道同步(见 common/training_runner.go)。
|
||||
|
||||
@@ -38,6 +41,12 @@ func TrainingModelPath(ctx context.Context, modelName string) string {
|
||||
return filepath.Join(DatasetDir(ctx), "trainings", modelName+".tflite")
|
||||
}
|
||||
|
||||
// TrainingWeightsPath 增量训练基座权重存档路径(trainings/weights/<基名>.pt,基名同 tflite 基名;
|
||||
// 存在则下次训练推训练机热启动,删除即回落全量基座)
|
||||
func TrainingWeightsPath(ctx context.Context, baseName string) string {
|
||||
return filepath.Join(DatasetDir(ctx), "trainings", "weights", baseName+".pt")
|
||||
}
|
||||
|
||||
// Sha256Hex 计算文件内容 SHA-256 十六进制(模型版本校验用)
|
||||
func Sha256Hex(data []byte) (string, error) {
|
||||
sum := sha256.Sum256(data)
|
||||
|
||||
Binary file not shown.
@@ -8,7 +8,9 @@
|
||||
任务参数(Go 侧写入,字段相对训练机 workdir):
|
||||
workdir 训练机工作目录(脚本 / yolov8s.pt / venv 所在),启动即 chdir
|
||||
yolo 训练集目录(含 dataset.yaml),相对 workdir
|
||||
model 训练基座权重文件名(workdir 下,默认 yolov8s.pt)
|
||||
model 训练基座权重文件名(workdir 下,默认 yolov8s.pt;增量训练时为 Go 侧推到
|
||||
trainings/weights/<基名>.pt 的上次成功权重,类别数不符时 ultralytics
|
||||
自动重建检测头、backbone 热启动)
|
||||
imgsz 训练/导出分辨率(默认 1280,与端侧推理对齐)
|
||||
epochs 训练轮数
|
||||
batch 批大小
|
||||
@@ -19,10 +21,13 @@
|
||||
|
||||
产物契约:
|
||||
log_file {"epoch":1,"total":150,"metrics":{"metrics/mAP50(B)":0.87,...}}
|
||||
result_file {"metrics":{...},"names":["pheasant","suspect"],"best_tflite":"runs/tasks/<id>/weights/best.tflite",
|
||||
result_file {"metrics":{...},"names":["pheasant","suspect"],
|
||||
"best_tflite":"runs/tasks/<id>/train/weights/best.tflite",
|
||||
"best_pt":"runs/tasks/<id>/train/weights/best.pt",
|
||||
"tflite_check":{"ok":true,"reason":"","inputs":[...],"outputs":[...]}}
|
||||
result_file 存在 = 训练完成;异常时写 {"error":"..."},Go 侧据以置失败并展示原因。
|
||||
tflite_check.ok=false(产物 shape 异常)时 Go 侧置训练失败并带出 reason。
|
||||
best_pt 供 Go 侧存档 workspace/trainings/weights/ 作下次增量基座(2026-09-09)。
|
||||
"""
|
||||
|
||||
import argparse
|
||||
@@ -251,7 +256,12 @@ def main():
|
||||
|
||||
try:
|
||||
from ultralytics import YOLO
|
||||
model = YOLO(task.get("model") or "yolov8s.pt")
|
||||
# 基座权重相对 workdir(增量训练时为 Go 推送的 trainings/weights/<基名>.pt);
|
||||
# 仅在 workdir 下确有该文件时绝对化,裸名(yolov8s.pt)缺文件仍走 ultralytics 自下载
|
||||
model_path = task.get("model") or "yolov8s.pt"
|
||||
if not os.path.isabs(model_path) and os.path.exists(os.path.join(base, model_path)):
|
||||
model_path = os.path.join(base, model_path)
|
||||
model = YOLO(model_path)
|
||||
register_callback(model)
|
||||
if is_mps:
|
||||
def _mps_cache(_trainer):
|
||||
@@ -321,11 +331,14 @@ def main():
|
||||
if isinstance(v, (int, float)) and math.isfinite(v):
|
||||
clean_metrics[k] = round(float(v), 5)
|
||||
|
||||
# best_tflite 相对 workdir,Go 侧按此路径拉取(不再打包 zip,仅回传 tflite)
|
||||
# best_tflite/best_pt 相对 workdir,Go 侧按此路径拉取(不再打包 zip,仅回传 tflite/pt);
|
||||
# best_pt 存档 trainings/weights/ 作下次增量基座(不存在不致命,缺省空串)
|
||||
best_pt = save_dir / "weights" / "best.pt"
|
||||
write_result(result_file, {
|
||||
"metrics": clean_metrics,
|
||||
"names": names,
|
||||
"best_tflite": os.path.relpath(best_tflite, base),
|
||||
"best_pt": os.path.relpath(best_pt, base) if best_pt.exists() else "",
|
||||
"tflite_check": tflite_check,
|
||||
})
|
||||
except Exception:
|
||||
|
||||
@@ -389,6 +389,7 @@ training:
|
||||
- **发起训练异步化(2026-08-27)**:发起请求仅做校验(数据集存在 / `prepareYoloSet` 有标注 / Serial 内并发检查)+ 落任务记录即返回(毫秒级);训练机侧准备(写任务参数 → ssh tar 同步数据集 → 启动进程,耗时可达分钟级)在后台协程执行(`context.Background()`,与预标注 `runDetection` 同模式),任何一步失败经 `finishFailed` 置任务 failed 由列表/轮询呈现——此前同步执行超过管理端 axios 10s 超时,出现「任务已落库但前端报 timeout」的不一致
|
||||
- **GPU 独占排队(2026-09-03,替代「并发度 1 拒绝」)**:训练机单 GPU 无法并行两任务(显存),并发度 1 语义不变——已有 running 时新任务**不再拒绝**,落 `queued` 排队;10s 轮询在 running 结束后自动晋级最老 queued 为 running(CAS 防竞态后起后台协程做训练机准备);双档/多数据集可一次发起一串,训练机串行逐个执行。**同数据集同档位防重**:发起时检查该 (数据集,档位) 是否已有 running/queued 任务,有则拒绝(防双击/重复请求——排队不再拒绝后双档各自独立排队,同档重复提交会白跑两轮)。**发起校验与晋级都重新 `prepareYoloSet`**:请求时校验有标注即可(立即报错),晋级时重新打包(取发起后新标注,拆分 80/20 随任务时刻新鲜);数据集改名/删图期间排队任务晋级失败即置 failed 由列表呈现
|
||||
- 产物拉取(2026-08-27 重构;命名 2026-08-28 改;**双档位 2026-09-03**):成功后只拉 `best.tflite` 直写服务器 `workspace/trainings/<文件名前缀>.tflite`(s 档,前缀空回退数据集名)或 `<文件名前缀>_n.tflite`(n 档);原子覆盖,无 per-task 存档、不再打包 zip。s 档文件名与存量一致(存量已发布文件/旧 App 下载地址不变),n 档 `_n` 后缀区分
|
||||
- **增量训练(2026-09-09)**:单物种与综合任务(s/n 各自独立 lineage,正负样本全集照常打包)自动热启动——任务基名同 tflite 基名规则(单物种 `<前缀或数据集名>[_n]`、综合 `combined[_n]`),服务器存档 `workspace/trainings/weights/<基名>.pt`:发起晋级时该文件存在则经 runner **PushArtifact**(新增接口:subprocess 本机拷贝 / ssh stdin 管道)推到训练机 `trainings/weights/<基名>.pt`,task.json `model` 指向它;不存在回落 config 基座(yolov8s/n.pt,首次全量)。训练脚本 result.json 增 `best_pt` 字段,finishSuccess 拉 tflite 后顺带拉 best.pt 原子覆盖回存档——**pt 拉取失败仅记日志不置失败**(tflite 才是服务产物,权重缺档下次自动回落全量)。类别数/类别名变化无需特判:ultralytics 加载旧权重 nc 不匹配自动重建检测头、backbone 热启动。从零重训 = 删 `trainings/weights/<基名>.pt` 即回落,无独立开关
|
||||
- 写操作走 `common.Serial()` 单写者(SQLite 无 WAL,与既有链路一致);任务状态更新(进度轮询)为高频写,单独小事务
|
||||
|
||||
### 模型版本(每数据集每档位一个模型,多模型体系)
|
||||
|
||||
Reference in New Issue
Block a user