This commit is contained in:
2026-09-09 21:05:25 +08:00
parent 87ab06221c
commit c590f74b1e
14 changed files with 229 additions and 208 deletions
@@ -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 后
// 综合(combineddatasetId=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,