From c590f74b1e2a80f790c771f3635846213ec7a771 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E6=96=8C?= <259278618@qq.com> Date: Wed, 9 Sep 2026 21:05:25 +0800 Subject: [PATCH] 1 --- flutter_app/lib/camera/camera_view_model.dart | 10 +- flutter_app/lib/camera/detection_overlay.dart | 10 +- .../lib/camera/model_catalog_section.dart | 145 +++++++----------- .../lib/detection/detection_result.dart | 9 +- flutter_app/lib/detection/visual_prior.dart | 5 +- flutter_app/lib/models/model_manager.dart | 134 ++++++---------- flutter_app/pubspec.yaml | 2 +- server/README.md | 2 +- server/biz/service/training.go | 55 +++++-- server/common/training_runner.go | 34 ++++ server/common/workspace.go | 9 ++ server/data/observer.db | Bin 2461696 -> 2461696 bytes server/training/train_server.py | 21 ++- server/技术设计.md | 1 + 14 files changed, 229 insertions(+), 208 deletions(-) diff --git a/flutter_app/lib/camera/camera_view_model.dart b/flutter_app/lib/camera/camera_view_model.dart index 43985b7..d77db82 100644 --- a/flutter_app/lib/camera/camera_view_model.dart +++ b/flutter_app/lib/camera/camera_view_model.dart @@ -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 motionRegions, List 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; } diff --git a/flutter_app/lib/camera/detection_overlay.dart b/flutter_app/lib/camera/detection_overlay.dart index 30614bc..561672d 100644 --- a/flutter_app/lib/camera/detection_overlay.dart +++ b/flutter_app/lib/camera/detection_overlay.dart @@ -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; diff --git a/flutter_app/lib/camera/model_catalog_section.dart b/flutter_app/lib/camera/model_catalog_section.dart index d22e898..b1f2fd1 100644 --- a/flutter_app/lib/camera/model_catalog_section.dart +++ b/flutter_app/lib/camera/model_catalog_section.dart @@ -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 = >{}; 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 onSelect; - const _ModeToggle({required this.manager}); - - /// 展示顺序(与内部档位常量解耦) - static const List _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 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 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, diff --git a/flutter_app/lib/detection/detection_result.dart b/flutter_app/lib/detection/detection_result.dart index 1687958..2d90ebf 100644 --- a/flutter_app/lib/detection/detection_result.dart +++ b/flutter_app/lib/detection/detection_result.dart @@ -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, diff --git a/flutter_app/lib/detection/visual_prior.dart b/flutter_app/lib/detection/visual_prior.dart index edea92e..3dfc19a 100644 --- a/flutter_app/lib/detection/visual_prior.dart +++ b/flutter_app/lib/detection/visual_prior.dart @@ -40,8 +40,9 @@ class VisualPrior { if (results.isEmpty || width <= 0 || height <= 0) return results; final kept = []; 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; diff --git a/flutter_app/lib/models/model_manager.dart b/flutter_app/lib/models/model_manager.dart index 9795e30..a571b97 100644 --- a/flutter_app/lib/models/model_manager.dart +++ b/flutter_app/lib/models/model_manager.dart @@ -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//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? _inFlight; - /// 目标档待办清单(2026-09-09):当前 [mode] 下「未下载」或「本地版本落后 - /// 服务器」的模型条目,切档/目录刷新时重算,UI 出横幅由用户手动一键下载/更新 - List _modePending = const []; - /// 模型文件/激活集变更版本戳:下载完成或激活变化 +1, /// UI 据此判断是否需要重建推理 worker(2026-09-01 自动更新引入) int _revision = 0; @@ -147,13 +149,10 @@ class ModelManager extends ChangeNotifier { /// 服务器目录(弹层模型清单展示用;同一数据集可能 s/n 两行) List get catalog => _catalog; - /// 识别目标档位(默认 s 高精度):用户偏好,持久化;只决定卡片按钮与 - /// 首次下载的自动激活档,不直接切换已在运行的模型(运行看激活集) + /// 识别档位偏好(s 高精度 / n 高性能):作为各卡默认目标档 + /// (不直接切换运行——运行由激活集驱动) String get mode => _mode; - /// 目标档待办清单(未下载 / 本地版本落后),切档时检测、UI 出横幅手动触发 - List 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 setMode(String variant) async { - if (variant != kVariantS && variant != kVariantN) return; - if (_mode == variant) return; - _mode = variant; + /// 切换识别档位偏好(s 高精度 / n 高性能):只改默认目标档并持久化。 + Future setMode(String mode) async { + if (mode != kVariantS && mode != kVariantN) return; + if (_mode == mode) return; + _mode = mode; await _saveMode(); - await refreshModePending(); notifyListeners(); } - /// 重算目标档待办:未下载(无文件)或本地版本/sha 与服务器不符(有新版本)。 - /// 下载中的条目跳过(落地后自然移出) - Future refreshModePending() async { - final out = []; - 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 refresh() { if (_refreshing) return _inFlight ?? Future.value(); @@ -287,9 +268,9 @@ class ModelManager extends ChangeNotifier { // 无缓存且未拉取成功(目录确为空):无从同步,等下次刷新 if (_catalog.isEmpty && !fetched) return; - // 只拉目录不下载;扫描本地已有模型文件供清单展示。版本新旧都算已下载: - // 旧版本文件由 autoUpdate 静默补齐,无需用户看到「下载」按钮再下。 - // 缓存目录同样扫描:离线重开也能正确标出已下载档位 + // 只拉目录不下载;扫描本地已有模型文件供清单展示(版本是否落后由 + // 卡片对照目录版本提示「更新」)。缓存目录同样扫描:离线重开也能正确 + // 标出已下载档位 final downloaded = {}; 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 _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 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; - 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?> _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; - } catch (_) { - return null; - } - } - Future _rootDir() async { if (_rootDirOverride != null) return _rootDirOverride(); final support = await getApplicationSupportDirectory(); @@ -688,8 +648,8 @@ class ModelManager extends ChangeNotifier { return dir; } - /// 目录条目对应的模型文件是否已存在本地(不校验版本:旧版本视为已下载, - /// 新版本由 autoUpdate 自动补齐) + /// 目录条目对应的模型文件是否已存在本地(不校验版本:文件已存在即标已下载, + /// 是否落后由卡片端对照目录版本提示「更新」) Future _hasFile(ModelCatalogItem item) async { final root = await _rootDir(); return File('${root.path}/${_subPath(item.datasetId, item.variant)}/model.tflite') diff --git a/flutter_app/pubspec.yaml b/flutter_app/pubspec.yaml index 2eb8034..d594794 100644 --- a/flutter_app/pubspec.yaml +++ b/flutter_app/pubspec.yaml @@ -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 diff --git a/server/README.md b/server/README.md index 1875334..a8cb44f 100644 --- a/server/README.md +++ b/server/README.md @@ -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_ 前缀防跨数据集重名),产物/发布/目录下发走现有链路,文件基名 `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_ 前缀防跨数据集重名),产物/发布/目录下发走现有链路,文件基名 `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 用户的低质统计,见「标注众包赚时长」) | diff --git a/server/biz/service/training.go b/server/biz/service/training.go index d1a9216..ffaa4f2 100644 --- a/server/biz/service/training.go +++ b/server/biz/service/training.go @@ -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{ diff --git a/server/common/training_runner.go b/server/common/training_runner.go index 1b44ce9..20a6342 100644 --- a/server/common/training_runner.go +++ b/server/common/training_runner.go @@ -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) diff --git a/server/common/workspace.go b/server/common/workspace.go index 3513796..8cf95c7 100644 --- a/server/common/workspace.go +++ b/server/common/workspace.go @@ -14,6 +14,9 @@ import ( // datasets// 数据集图片(平铺,文件名唯一,标注存 DB dataset_image.labels_json) // trainings/.tflite 某数据集当前生效模型(训练成功即直写,客户端固定下载,无存档回退; // = 数据集文件名前缀 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) diff --git a/server/data/observer.db b/server/data/observer.db index 9cb96ca6550d333de0ca2e618bafc120a3f71c21..4e338338469ae4c23bc8105bd0537180e00720e3 100644 GIT binary patch delta 10207 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.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//weights/best.tflite", + result_file {"metrics":{...},"names":["pheasant","suspect"], + "best_tflite":"runs/tasks//train/weights/best.tflite", + "best_pt":"runs/tasks//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: diff --git a/server/技术设计.md b/server/技术设计.md index dcb1e53..c0386c6 100644 --- a/server/技术设计.md +++ b/server/技术设计.md @@ -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,与既有链路一致);任务状态更新(进度轮询)为高频写,单独小事务 ### 模型版本(每数据集每档位一个模型,多模型体系)