This commit is contained in:
2026-09-03 17:50:23 +08:00
parent 7bfafc7be3
commit 77d3aad6fc
45 changed files with 1398 additions and 1088 deletions
+39 -14
View File
@@ -62,34 +62,59 @@ GPU delegate 默认允许 FP16 计算(YOLO 类精度损失可忽略);如
## 模型热更新(多数据集模型)
模型与 APK 更新走**独立通道**:启动时拉取 `GET /api/v1/app/update` 随附的
`models` 目录(公开接口,无需登录),与 `UpdateChecker` 的 APK 检查并行。
模型与 APK 更新走**独立通道**:进入视野页时后台同步 `GET /api/v1/app/update`
随附的 `models` 目录(公开接口,无需登录;不阻塞相机预览启动),与
`UpdateChecker` 的 APK 检查并行。
- **目录条目**`{datasetId, datasetName, variant, version, labels[], sizeBytes, sha256,
downloadUrl, coverUrl}`——**双档位(2026-09-03**:每数据集至多 2 条 = 高识别 s
downloadUrl, coverUrl}`——**双档位(2026-09-03**:每数据集至多 2 条 = 高精度 s
(@1280 精度优先,默认)+ 高性能 n(@704 速度优先)各自的当前版本,条目带 `variant`
(s/n);服务器未发布模型时不返回 `models` 字段,App 无模型可用,
相机页仅预览不识别。
- **下载入口**:相机页设置弹层「模型清单」按需下载/使用(封面缩略图 2 列网格,
每数据集两档各占一格并带档位角标;未下载点击「使用」显示进度,完成自动激活;
已激活再次点击取消;下载中可取消)。**档位切换**:弹层顶部「识别模式」分段控件
(s 高识别 / n 高性能,默认 s)持久化本地,切换即热加载新档位已激活模型。
**同一物种合并一张卡**,卡上双档行标注各自版本与状态(使用中绿色高亮);点
「下载」一次性补下该动物高精度+高性能两个已训练文件——各档独立进度逐条展示、
可取消(已下过的档不重复下),**目标档落地即自动启用、伴档备好**;目标档已
启用显示「使用中」点击取消使用。弹层高度上限 70% 屏高(防盖满全屏无法关闭)。
**档位(2026-09-03 修订)**:同一物种一次只运行一个档位——激活某档会自动停用
同物种另一档,**不同物种可用不同档位并行识别**(如雉鸡用高性能、斑鸠用高精度)。
弹层顶部「识别模式」分段控件(高性能 / 高精度,默认高精度,持久化本地;
2026-09-03 高性能移到左位)是**目标档偏好**:不直接切换运行中的模型,
只决定卡片按钮面向哪个档。卡片主按钮(2026-09-03 简化:不再出现「改用X」、
不再显示当前运行档提示):目标档未下载 →「下载」(落地自动启用、伴档备好;
同一动物另一档在使用时补下目标档后自动切换过去);已下载未启用 →「使用」
点击即用(另一档在使用会被自动停用);启用中 →「使用中」点击取消使用。
- **存储**:应用私有目录 `models/<datasetId>/<variant>/`(双档位 2026-09-03
原无 variant 目录与存量 s 档一致——s 档复用 `models/<datasetId>/` 同级读取,
目录键 = 档位标识符),含 `model.tflite`、`labels.json`、`meta.json`
meta 记录 `{version, sha256}`)。版本与摘要都未变化时跳过下载;变化则下载到
`.part` 临时文件、sha256 校验通过后原子 rename 替换,失败重试一次并保留旧模型,
下次启动再试——检查记账按 `(datasetId, variant)` 独立。
- **目录缓存(2026-09-03)**:最近一次成功拉取的模型目录落盘应用私有目录
`catalog.json`;每次刷新先载入缓存、展示立即可用(弹层离线/弱网也有内容),
网络成功后再以权威目录覆盖缓存。清理与自动更新只在网络拉取成功时执行:
离线降级不清文件、不触发下载——升级后断网首启不会误删已下载模型。
- **清理**:服务器下线的数据集下次同步时删除本地对应目录(两档都无条目时才删)。
- **并行推理合并**:识别时加载**当前识别档位**下全部已激活模型(`DetectorWorker`
isolate 内逐模型加载,单个失败不影响其他),同帧各模型独立推理后按类别分组做
**跨模型 NMS**(同类别不同模型检出同一目标取高分去重,不同类别互不压制),
结果叠加 `modelName`(数据集名+档位)标注来源。
- **识别会话制(2026-09-03 修订)**:每次进入视野页即开始新识别会话——从
**仅预览**开始、**不自动恢复上次使用的模型**(上次崩溃/坏模型不会在下一次打开时
自动复现,用户总能进入模型清单调整;这也是打开即慢的根因消除项:进入页面不再
读模型/重建推理器)。识别需在模型清单**手动启用**,启用状态只在本会话内生效
(不跨会话持久化);已下载文件常驻本地,「使用」即点即用不重新下载。
服务器新版本仅对**已下载文件**后台自动补齐,补齐不改变启用状态。
- **并行推理合并**:识别时加载**本会话已启用**的全部模型(激活集即实际运行集,
会话内每数据集至多一个档位;`DetectorWorker` isolate 内逐模型加载,单个失败
不影响其他;worker 构建完成后原地挂到已启动的相机流,不重启相机),
同帧各模型独立推理后做**跨模型 NMS**(所有模型的框统一按 IoU 去重取高分——
异类别重叠也压制,实测多个模型会对同一目标检出不同类别),结果叠加 `modelName`
(数据集名)标注来源。
实现:`lib/models/model_manager.dart`(下载/校验/持久化,`ModelManager`
单例 + ChangeNotifier,条目身份含档位)、`lib/detection/detector_worker.dart`
(多模型并行推理与 `mergeAcrossModels`)、`lib/camera/camera_screen.dart`
(启动同步 + 设置弹层「识别模式」切换 + 诊断行展示模型列表)。
实现:`lib/models/model_manager.dart`(下载/校验/清单/会话制单档激活收敛,
`ModelManager` 单例 + ChangeNotifier,条目身份含档位)、
`lib/detection/detector_worker.dart`(多模型并行推理与 `mergeAcrossModels`)、
`lib/camera/camera_screen.dart`(会话重置从仅预览开始 + 先出预览后台同步 +
设置弹层「识别模式」目标档切换 + 底部状态胶囊:识别延迟)、
`lib/camera/model_catalog_section.dart`(清单网格:按数据集合并卡片、
双档下载与「使用/使用中/下载」主按钮动作,2026-09-03 简化文案)。
- **模型输入是 NHWC**:训练导出的模型需做字节级手术(开头 TRANSPOSE→RESHAPE
输入 [1,320,320,3])再发布给 App,否则 iOS 报
@@ -1,8 +1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.INTERNET"/>
<!-- App 内更新安装 APKPackageInstaller 会话安装,见 InstallerChannel.kt -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
<application
android:label="视野"
android:name="${applicationName}"
@@ -1,196 +0,0 @@
package com.example.observer
import android.app.PendingIntent
import android.content.Intent
import android.content.pm.PackageInstaller
import android.net.Uri
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.provider.Settings
import android.util.Log
import java.io.File
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
/**
* 原生 APK 安装通道(App 内更新安装):PackageInstaller 会话安装,
* 安装进度经 EventChannel 实时回传 Flutter(下载进度由 Flutter 侧 http 流式下载自算)。
*
* 通道:
* - MethodChannel "observer/installer"install(path)
* - result.success("installing"):已提交安装
* - result.success("permission_required"):未开「安装未知应用」,已拉起系统设置页
* - EventChannel "observer/installer/progress"{event: progress/finished/failed, ...}
*/
class InstallerChannel(
private val activity: FlutterActivity,
private val engine: FlutterEngine,
) {
private val messenger = engine.dartExecutor.binaryMessenger
private val method = MethodChannel(messenger, "observer/installer")
private val progress = EventChannel(messenger, "observer/installer/progress")
private var progressSink: EventChannel.EventSink? = null
private var activeSession: PackageInstaller.Session? = null
/// 安装完成轮询兜底开关:部分 ROM(如 MIUI)点确认后不回调
/// onProgressChanged/onFinished,仅靠回调会永久停在「安装中」。
@Volatile
private var installSettled = false
fun register() {
method.setMethodCallHandler { call: MethodCall, result: MethodChannel.Result ->
when (call.method) {
"install" -> install(call.argument<String>("path") ?: "", result)
else -> result.notImplemented()
}
}
progress.setStreamHandler(object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
progressSink = events
}
override fun onCancel(arguments: Any?) {
progressSink = null
}
})
}
private fun install(path: String, result: MethodChannel.Result) {
installSettled = false
val file = File(path)
Log.d(TAG, "install: path=$path exists=${file.exists()} len=${file.length()}")
if (!file.exists()) {
result.error("FILE_NOT_FOUND", "APK 文件不存在: $path", null)
return
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
!activity.packageManager.canRequestPackageInstalls()
) {
Log.d(TAG, "install: permission_required")
val intent = Intent(
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
Uri.parse("package:${activity.packageName}"),
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
activity.startActivity(intent)
result.success("permission_required")
return
}
try {
val pm = activity.packageManager
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL).apply {
setSize(file.length())
}
val sessionId = pm.packageInstaller.createSession(params)
val session = pm.packageInstaller.openSession(sessionId)
activeSession = session
Log.d(TAG, "install: session=$sessionId opened")
// API 36 起 registerSessionCallback(int, ...) 变体被移除,只剩全局注册形式,
// 回调按 sessionId 过滤,避免响应其他会话事件
val callback = object : PackageInstaller.SessionCallback() {
override fun onCreated(id: Int) {}
override fun onBadgingChanged(id: Int) {}
override fun onActiveChanged(id: Int, active: Boolean) {}
override fun onProgressChanged(id: Int, progressPercent: Float) {
if (id != sessionId) return
Log.d(TAG, "callback onProgressChanged=$progressPercent")
emit("progress", "progress" to progressPercent.toInt())
}
override fun onFinished(id: Int, success: Boolean) {
if (id != sessionId) return
Log.d(TAG, "callback onFinished success=$success")
installSettled = true
emit("finished", "success" to success)
pm.packageInstaller.unregisterSessionCallback(this)
activeSession = null
}
}
pm.packageInstaller.registerSessionCallback(callback, Handler(Looper.getMainLooper()))
// 安装前已装版本号(更新安装成功后必然变化,轮询兜底依据)
val installedCode = try {
pm.getPackageInfo(activity.packageName, 0).versionCode
} catch (e: Exception) {
-1
}
// 写 APK 到会话:1MB 缓冲流式拷贝,完成后 commit 弹系统确认框
Thread {
try {
Log.d(TAG, "writeThread: start")
session.openWrite("apk", 0, file.length()).use { out ->
file.inputStream().use { input ->
val buf = ByteArray(1 shl 20)
while (true) {
val n = input.read(buf)
if (n < 0) break
out.write(buf, 0, n)
}
}
}
Log.d(TAG, "writeThread: written, commit")
// commit 的 status receiver 必须 MUTABLE:系统要向 intent 写入安装结果,
// FLAG_IMMUTABLE 直接抛 "The commit() status receiver should come from a
// mutable PendingIntent"(模拟器 AOSP 严格校验;部分 ROM 不校验但回调丢失)
val sender = PendingIntent.getActivity(
activity,
0,
Intent(activity, MainActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE,
).intentSender
session.commit(sender)
} catch (e: Exception) {
Log.e(TAG, "writeThread: failed", e)
session.abandon()
emit("failed", "error" to (e.message ?: "安装会话写入失败"))
activeSession = null
}
}.start()
// 完成轮询兜底:回调缺失时靠版本号变化判定安装成功(2s 间隔,最长 120s)
Thread {
var waited = 0
while (!installSettled && waited < 120_000) {
Thread.sleep(2000)
waited += 2000
val now = try {
pm.getPackageInfo(activity.packageName, 0).versionCode
} catch (e: Exception) {
-1
}
if (now != installedCode && now != -1) {
Log.d(TAG, "poll: versionCode changed $installedCode->$now, settled")
installSettled = true
emit("finished", "success" to true)
return@Thread
}
}
if (!installSettled) {
Log.d(TAG, "poll: timeout after 120s, not settled")
emit("failed", "error" to "安装超时,请重试")
}
}.start()
result.success("installing")
} catch (e: Exception) {
result.error("INSTALL_FAILED", e.message, null)
}
}
private fun emit(event: String, vararg pairs: Pair<String, Any>) {
// EventSink.success 内部 dispatchPlatformMessage 要求主线程(@UiThread 校验),
// 写 APK 线程 / 轮询线程直接调用会崩溃,必须投递主线程
Handler(Looper.getMainLooper()).post {
Log.d(TAG, "emit: $event $pairs")
progressSink?.success(mapOf("event" to event, *pairs))
}
}
companion object {
private const val TAG = "InstallerChannel"
}
}
@@ -1,54 +1,10 @@
package com.example.observer
import android.content.Intent
import android.content.pm.PackageInstaller
import android.os.Build
import android.os.Bundle
import android.util.Log
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
class MainActivity : FlutterActivity() {
private var cameraChannel: CameraChannel? = null
private var installerChannel: InstallerChannel? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
handleInstallIntent(intent)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleInstallIntent(intent)
}
/**
* Android 14+ 安装确认回调:PackageInstaller 会话 commit 后如需要用户确认
* (确认对话框/安装未知来源授权),系统不直接弹 packageinstaller 界面,
* 而是经 commit 传入的 statusReceiver(指向本 Activity 的 PendingIntent
* 回传 STATUS_PENDING_USER_ACTION,并在 EXTRA_INTENT 里附上确认界面 Intent
* ACTION_CONFIRM_INSTALL → InstallStart),由调用方负责启动。
* 不处理则确认框永不出现,安装卡在 0.8% 直至超时(AOSP 见
* PackageInstallerSession.sendOnUserActionRequired)。
*/
private fun handleInstallIntent(intent: Intent?) {
val status = intent?.getIntExtra(PackageInstaller.EXTRA_STATUS, -1) ?: return
if (status != PackageInstaller.STATUS_PENDING_USER_ACTION) return
val confirm = if (Build.VERSION.SDK_INT >= 33) {
intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)
} else {
@Suppress("DEPRECATION")
intent.getParcelableExtra(Intent.EXTRA_INTENT)
}
if (confirm == null) return
try {
confirm.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(confirm)
Log.d(TAG, "handleInstallIntent: started confirm ${confirm.component}")
} catch (e: Exception) {
Log.e(TAG, "handleInstallIntent: start confirm failed", e)
}
}
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
@@ -57,12 +13,6 @@ class MainActivity : FlutterActivity() {
// configureFlutterEngineonCreate 阶段)只注册通道与 viewFactory
// 实际 bindToLifecycle 由 Flutter 相机页 start 时触发(此时已 RESUMED
cameraChannel = CameraChannel(this, flutterEngine).also { it.register() }
// App 内更新安装 APKPackageInstaller 会话安装 + 进度回传)
installerChannel = InstallerChannel(this, flutterEngine).also { it.register() }
}
companion object {
private const val TAG = "MainActivity"
}
override fun onDestroy() {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 B

After

Width:  |  Height:  |  Size: 879 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 B

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 B

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 B

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 B

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 B

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 762 B

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 11 KiB

+4 -9
View File
@@ -70,24 +70,19 @@ class _StartupGateState extends State<StartupGate> {
Navigator.of(context).pushReplacementNamed('/terms');
return;
}
// 版本更新检查(公开接口,无需登录态;仅 Android):服务器版本高于
// 「已装版本与已确认接受版本」中的较大者即强制更新,弹全屏阻塞页。
// APK 版本号不递增时,点过「立即更新」的 accepted 版本参与比较,
// 更新完成后再次启动不反复提示。
// 版本更新检查(公开接口,无需登录态;仅 Android):服务器版本高于已装
// 版本即强制更新,弹全屏阻塞页 → 浏览器下载 APK 手动安装
// 2026-09-03App 内安装在小米等 ROM 失败,改浏览器下载)
final info = await UpdateChecker().fetch();
final current = await PackageInfo.fromPlatform();
if (!mounted) return;
final session = context.read<SessionStore>();
final acceptedUpdate = await session.readAcceptedUpdateVersion();
if (!mounted) return;
if (UpdateChecker.needsUpdate(info.version, current.version, acceptedUpdate)) {
if (UpdateChecker.needsUpdate(info.version, current.version)) {
Navigator.of(context).pushReplacement(MaterialPageRoute(
builder: (_) => UpdateScreen(
version: info.version,
url: UpdateChecker.downloadUrl(),
notes: info.notes,
onUpdateAccepted: () =>
session.saveAcceptedUpdateVersion(info.version),
),
));
return;
-16
View File
@@ -2,17 +2,13 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart';
/// 登录会话持久化:token + 手机号存 secure storage。
/// 启动时读取判断是否已登录;登出/401 时清除回登录页。
/// 另存「已确认更新版本」:用户点过「立即更新」后记录服务器版本号,
/// 与 APK 内 versionName 取较大者参与更新判断(APK 版本号不递增也不会反复提示)。
class SessionStore {
static const _storage = FlutterSecureStorage();
static const _tokenKey = 'auth_token';
static const _phoneKey = 'auth_phone';
static const _acceptedUpdateKey = 'accepted_update_version';
static String? _cachedToken;
static String? _cachedPhone;
static String? _cachedAcceptedUpdate;
Future<String?> readToken() async {
if (_cachedToken != null) return _cachedToken;
@@ -24,18 +20,6 @@ class SessionStore {
return _cachedPhone = await _storage.read(key: _phoneKey);
}
/// 用户点过「立即更新」的服务器版本号(空串 = 从未接受过更新提示)
Future<String> readAcceptedUpdateVersion() async {
if (_cachedAcceptedUpdate != null) return _cachedAcceptedUpdate!;
return _cachedAcceptedUpdate =
(await _storage.read(key: _acceptedUpdateKey)) ?? '';
}
Future<void> saveAcceptedUpdateVersion(String version) async {
_cachedAcceptedUpdate = version;
await _storage.write(key: _acceptedUpdateKey, value: version);
}
Future<void> save(String phone, String token) async {
_cachedPhone = phone;
_cachedToken = token;
+268 -136
View File
@@ -47,6 +47,79 @@ class _CameraScreenState extends State<CameraScreen> {
/// ModelManager revision 快照:模型文件更新(自动更新下载新版)也需重建 worker
int _lastRevision = -1;
/// C 端状态胶囊:3 秒内连点 5 次切换完整开发诊断(远程收截图报障仍可取数)
bool _devInfo = false;
final List<DateTime> _devTaps = [];
/// 推理 worker 后台重建中(启用/停用模型触发):期间 modelReady 仍为 false
/// 胶囊与顶部横幅显示「识别准备中」,不误报「模型加载失败」
bool _workerPending = false;
void _onDevTaps() {
final now = DateTime.now();
_devTaps.add(now);
_devTaps
.removeWhere((t) => now.difference(t) > const Duration(seconds: 3));
if (_devTaps.length < 5) return;
_devTaps.clear();
setState(() => _devInfo = !_devInfo);
}
/// 识别延迟分档(C 端直观读法:流畅 / 一般 / 偏慢)
({String label, Color color}) _latencyBucket(double avgMs) {
if (avgMs <= 200) return (label: '流畅', color: Colors.greenAccent);
if (avgMs <= 600) return (label: '一般', color: Colors.amberAccent);
return (label: '偏慢', color: Colors.redAccent);
}
/// C 端底部状态胶囊内容:设备识别延迟(近帧滚动平均)+ 当前识别模型
List<Widget> _statusCapsuleLines(CameraUiState s) {
if (!s.modelReady) {
if (_workerPending) {
// worker 后台构建中(用户刚启用模型):给个进行时状态,别误读为未启用
return const [
Text(
'识别准备中…',
style: TextStyle(color: Colors.white70, fontSize: 13),
),
];
}
return const [
Text(
'仅预览 · 未启用识别',
style: TextStyle(color: Colors.orangeAccent, fontSize: 12),
),
];
}
final avg = s.latencyAvgMs;
final lines = <Widget>[];
if (avg == null) {
lines.add(const Text(
'识别延迟 --',
style: TextStyle(color: Colors.white54, fontSize: 13),
));
} else {
final b = _latencyBucket(avg);
lines.add(Text(
'识别延迟 ${(avg / 1000).toStringAsFixed(2)} 秒 · ${b.label}',
style: TextStyle(
color: b.color,
fontSize: 13,
fontWeight: FontWeight.w600,
),
));
}
final names = ModelManager.instance.modelsLabel;
if (names.isNotEmpty && names != '未下载') {
lines.add(Text(
'识别模型:$names',
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white54, fontSize: 10.5),
));
}
return lines;
}
void _openSettings() {
final vm = _viewModel;
if (vm == null) return;
@@ -56,53 +129,66 @@ class _CameraScreenState extends State<CameraScreen> {
isScrollControlled: true,
backgroundColor: Colors.black87,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setSheetState) => SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('识别设置',
// 弹层高度 ≤ 70% 屏高:超过会盖满全屏使遮罩无处点按,弹层无法关闭
builder: (ctx, setSheetState) => ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(ctx).size.height * 0.7,
),
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'识别设置',
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.bold)),
const SizedBox(height: 12),
Row(
children: [
const Text('置信度阈值',
style:
TextStyle(color: Colors.white70, fontSize: 14)),
const Spacer(),
Text('${(_minScore * 100).toStringAsFixed(0)}%',
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
Row(
children: [
const Text(
'置信度阈值',
style: TextStyle(color: Colors.white70, fontSize: 14),
),
const Spacer(),
Text(
'${(_minScore * 100).toStringAsFixed(0)}%',
style: const TextStyle(
color: Colors.greenAccent,
fontSize: 14,
fontWeight: FontWeight.bold)),
],
),
Slider(
value: _minScore,
min: 0.05,
max: 0.50,
divisions: 45,
activeColor: Colors.greenAccent,
onChanged: (v) {
setSheetState(() => _minScore = v);
_analyzer?.worker?.setMinScore(v);
},
),
const SizedBox(height: 8),
const Text(
'阈值越低识别越灵敏(低分框越多,误报也可能增加)',
style: TextStyle(color: Colors.white54, fontSize: 12),
),
const SizedBox(height: 16),
const Divider(color: Colors.white12),
const SizedBox(height: 8),
ModelCatalogSection(manager: ModelManager.instance),
],
color: Colors.greenAccent,
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
],
),
Slider(
value: _minScore,
min: 0.05,
max: 0.50,
divisions: 45,
activeColor: Colors.greenAccent,
onChanged: (v) {
setSheetState(() => _minScore = v);
_analyzer?.worker?.setMinScore(v);
},
),
const SizedBox(height: 8),
const Text(
'阈值越低识别越灵敏(低分框越多,误报也可能增加)',
style: TextStyle(color: Colors.white54, fontSize: 12),
),
const SizedBox(height: 16),
const Divider(color: Colors.white12),
const SizedBox(height: 8),
ModelCatalogSection(manager: ModelManager.instance),
],
),
),
),
),
@@ -115,15 +201,20 @@ class _CameraScreenState extends State<CameraScreen> {
super.initState();
final oldPlatform = ui.PlatformDispatcher.instance.onError;
ui.PlatformDispatcher.instance.onError = (error, stack) {
setState(() => _globalError =
'Platform: $error\n${stack.toString().split('\n').take(3).join('\n')}');
setState(
() => _globalError =
'Platform: $error\n${stack.toString().split('\n').take(3).join('\n')}',
);
return oldPlatform?.call(error, stack) ?? false;
};
WidgetsBinding.instance.addPostFrameCallback((_) => _init());
// 相机页常亮:野外观察时保持屏幕不熄(离开页面时关闭)
WakelockPlus.enable();
// 每秒轮询原生侧帧状态:无帧时诊断行也能实时刷新(camErr/计数)
_statsTimer = Timer.periodic(const Duration(seconds: 1), (_) => _pollStats());
_statsTimer = Timer.periodic(
const Duration(seconds: 1),
(_) => _pollStats(),
);
// 模型清单激活集变化(下载完成自动激活/取消激活)时重建推理 worker
ModelManager.instance.addListener(_onModelsChanged);
}
@@ -142,29 +233,37 @@ class _CameraScreenState extends State<CameraScreen> {
setState(() => _permissionGranted = granted);
if (!granted) return;
// 模型热更新:启动拉取目录(只拉不下载),兜底等待;已下载/已激活模型
// 有新版本时 autoUpdate 自动重下,变化经 _onModelsChanged 重建 worker
try {
await ModelManager.instance
.refresh()
.timeout(const Duration(seconds: 30));
} catch (_) {}
await _reloadWorker();
// 新识别会话(2026-09-03):不恢复上次启用的模型——每次进入都从仅预览开始,
// 识别需用户在模型清单手动启用;崩溃/坏模型不会自动复现。激活集变化经
// _onModelsChanged 重建 worker 挂载
ModelManager.instance.resetForSession();
// 目录同步先行(缓存优先,2026-09-03):进页面立即触发——refresh 开头先把
// 上次成功目录从磁盘载入并 notify,设置弹层打开即有内容(不完全依赖网络);
// 网络拉取/自动更新在后台继续,全程不阻塞预览
unawaited(ModelManager.instance.refresh());
// 先出预览:占位 analyzer(无 worker)让相机立即启动,识别加载完再原地挂上
if (_analyzer == null) {
final vm = CameraViewModel(reminder: Reminder());
_viewModel = vm;
_analyzer = FrameAnalyzer(worker: null, viewModel: vm);
}
await _startCamera();
}
/// 用当前激活模型重建推理 worker(激活集变化/启动时调用);
/// worker 为 null(无激活模型或加载失败)时仅预览并提示。
/// 并发调用共享同一进行中的重建:refresh 通知触发的重建与 _init 的等待
/// 共用一个 Future,相机等重建完成后再启动(避免绑定旧 analyzer)。
/// 用当前激活模型重建推理 worker(启用/停用模型、模型文件更新时调用);
/// worker 为 null(无启用模型或加载失败)时仅预览并提示。
/// 并发调用共享同一进行中的重建:进行中时先等完成再复查,期间变化不丢失。
Future<void> _reloadWorker() {
final inFlight = _reloadInFlight;
if (inFlight != null) {
// 重建进行中:完成后按最新状态复查,期间的变化不丢失
return inFlight.then((_) => _reloadWorker()).catchError((_) {});
}
_reloadInFlight =
_doReloadWorker().whenComplete(() => _reloadInFlight = null);
if (mounted) setState(() => _workerPending = true);
_reloadInFlight = _doReloadWorker().whenComplete(() {
_reloadInFlight = null;
if (mounted) setState(() => _workerPending = false);
});
return _reloadInFlight!;
}
@@ -182,24 +281,31 @@ class _CameraScreenState extends State<CameraScreen> {
_lastRevision = mgr.revision;
final worker = await DetectorWorker.create(models: models);
_loadedModelIds = ids;
if (!mounted) {
worker?.dispose();
return; // 页面已关闭:不触碰可能已 dispose 的 vm/analyzer
}
final vm = _viewModel ?? CameraViewModel(reminder: Reminder());
vm.setModelReady(worker != null);
final analyzer = FrameAnalyzer(worker: worker, viewModel: vm);
final old = _analyzer;
if (!mounted) {
analyzer.dispose();
if (vm != _viewModel) vm.dispose();
// 重建后把设置页调过的阈值落到新 worker(避免静默回到默认值)
worker?.setMinScore(_minScore);
final existing = _analyzer;
if (existing == null) {
// 相机尚未绑定 analyzer(冷启动竞态兜底):直接以新 worker 建 analyzer
final analyzer = FrameAnalyzer(worker: worker, viewModel: vm);
setState(() {
_viewModel = vm;
_analyzer = analyzer;
});
if (_cameraController != null) {
await _cameraController!.start(analyzer);
}
return;
}
setState(() {
_viewModel = vm;
_analyzer = analyzer;
});
old?.worker?.dispose();
// 相机已启动:重启帧流绑定新 analyzer(start 内部先 stop 再订阅)
if (mounted && _cameraController != null) {
await _cameraController!.start(analyzer);
}
// 相机已绑定(预览中):worker 原地挂到既有 analyzer——帧流回调闭包捕获的
// 是 analyzer 对象本身,替换其 worker 双平台即时生效;重启相机会重新
// initializeiOS ~1s+)且预览闪断,一律避免(2026-09-03)
existing.attachWorker(worker);
}
void _onModelsChanged() {
@@ -310,7 +416,10 @@ class _CameraScreenState extends State<CameraScreen> {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('相机初始化失败', style: TextStyle(color: Colors.white)),
const Text(
'相机初始化失败',
style: TextStyle(color: Colors.white),
),
if (_initError != null)
Padding(
padding: const EdgeInsets.only(top: 8),
@@ -320,7 +429,9 @@ class _CameraScreenState extends State<CameraScreen> {
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.redAccent, fontSize: 11),
color: Colors.redAccent,
fontSize: 11,
),
),
),
TextButton(
@@ -361,8 +472,7 @@ class _CameraScreenState extends State<CameraScreen> {
right: 16,
top: MediaQuery.of(context).padding.top + 56,
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(8),
@@ -375,8 +485,9 @@ class _CameraScreenState extends State<CameraScreen> {
),
),
// 模型未加载时仅显示相机预览,不做检测标注(横幅置于顶栏下方,避免与底部诊断行重叠)
if (!vm.state.modelReady)
// 模型未加载时仅显示相机预览,不做检测标注(横幅置于顶栏下方,避免与底部诊断行重叠)
// worker 后台构建中不提示「加载失败」(modelReady 就绪前的一瞬)
if (!vm.state.modelReady && !_workerPending)
Positioned(
left: 16,
right: 16,
@@ -405,56 +516,87 @@ class _CameraScreenState extends State<CameraScreen> {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
'阈值:${(_minScore * 100).toStringAsFixed(0)}% 模型:${vm.state.modelReady ? ModelManager.instance.modelsLabel : '未加载'} 帧:${vm.state.framesReceived} 流:${camera?.streamCallbacks ?? 0} 推理:${vm.state.debugDetectCalls}次 异常:${vm.state.debugDetectErrors}次 处理:${vm.state.debugLastMs}ms 最高分:${(vm.state.debugHighestScore * 100).toStringAsFixed(1)}% 图:${vm.state.imageWidthPx}x${vm.state.imageHeightPx} 传感:${camera?.sensorOrientation ?? '-'} 屏转:${camera?.displayDegrees ?? '-'} 旋:${camera?.rotationDegrees ?? 0} turn:${camera?.quarterTurns ?? '-'}',
style: const TextStyle(color: Colors.white70, fontSize: 12),
// C 端状态胶囊:识别延迟 + 识别模型(连点 5 次展开完整开发诊断)
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: _onDevTaps,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 14, vertical: 6),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(16),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: _statusCapsuleLines(vm.state),
),
),
),
if (_nativeStats.isNotEmpty)
Text(
'原生:回调${_nativeStats['callbacks'] ?? '-'} 发出${_nativeStats['emitOk'] ?? '-'} 异常${_nativeStats['emitErr'] ?? '-'} 无订阅${_nativeStats['sinkNull'] ?? '-'} sink:${_nativeStats['sink'] ?? '-'} 配置:${_nativeStats['size'] ?? '-'} 发帧:${_nativeStats['emitSize'] ?? '-'} 错误:${_nativeStats['error'] ?? ''} 轮询:${_nativeStats['pollErr'] ?? 'ok'}${vm.state.results.isEmpty ? '' : ' 框1:(${vm.state.results.first.left.toStringAsFixed(2)},${vm.state.results.first.top.toStringAsFixed(2)},${vm.state.results.first.right.toStringAsFixed(2)},${vm.state.results.first.bottom.toStringAsFixed(2)})'}',
style: const TextStyle(
color: Colors.amberAccent, fontSize: 11),
// —— 开发诊断(默认隐藏,远程排查用)——
if (_devInfo) ...[
const SizedBox(height: 4),
const Text(
'开发诊断(连点上方状态胶囊 5 次收起)',
style: TextStyle(color: Colors.white24, fontSize: 10),
),
if (vm.state.debugYuv.isNotEmpty)
Text(
'yuv:${vm.state.debugYuv}',
style: const TextStyle(
color: Colors.yellowAccent, fontSize: 11),
),
if (camera != null)
Text(
'streaming:${camera.isStreaming} '
'camErr:${camera.errorDescription ?? ''}',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.cyanAccent, fontSize: 11),
),
if (vm.state.debugLastError != null)
Text(
vm.state.debugLastError!,
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.redAccent, fontSize: 11),
),
if (_globalError != null)
Text(
_globalError!,
maxLines: 3,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.redAccent, fontSize: 11),
'阈值:${(_minScore * 100).toStringAsFixed(0)}% 模型:${vm.state.modelReady ? ModelManager.instance.modelsLabel : '未加载'} 帧:${vm.state.framesReceived} 流:${camera?.streamCallbacks ?? 0} 推理:${vm.state.debugDetectCalls}次 异常:${vm.state.debugDetectErrors}次 处理:${vm.state.debugLastMs}ms 平均:${vm.state.latencyAvgMs?.toStringAsFixed(0) ?? '-'}ms 最高分:${(vm.state.debugHighestScore * 100).toStringAsFixed(1)}% 图:${vm.state.imageWidthPx}x${vm.state.imageHeightPx} 传感:${camera?.sensorOrientation ?? '-'} 屏转:${camera?.displayDegrees ?? '-'} 旋:${camera?.rotationDegrees ?? 0} turn:${camera?.quarterTurns ?? '-'}',
style: const TextStyle(color: Colors.white70, fontSize: 12),
),
if (_nativeStats.isNotEmpty)
Text(
'原生:回调${_nativeStats['callbacks'] ?? '-'} 发出${_nativeStats['emitOk'] ?? '-'} 异常${_nativeStats['emitErr'] ?? '-'} 无订阅${_nativeStats['sinkNull'] ?? '-'} sink:${_nativeStats['sink'] ?? '-'} 配置:${_nativeStats['size'] ?? '-'} 发帧:${_nativeStats['emitSize'] ?? '-'} 错误:${_nativeStats['error'] ?? ''} 轮询:${_nativeStats['pollErr'] ?? 'ok'}${vm.state.results.isEmpty ? '' : ' 框1:(${vm.state.results.first.left.toStringAsFixed(2)},${vm.state.results.first.top.toStringAsFixed(2)},${vm.state.results.first.right.toStringAsFixed(2)},${vm.state.results.first.bottom.toStringAsFixed(2)})'}',
style: const TextStyle(
color: Colors.amberAccent,
fontSize: 11,
),
),
if (vm.state.debugYuv.isNotEmpty)
Text(
'yuv:${vm.state.debugYuv}',
style: const TextStyle(
color: Colors.yellowAccent,
fontSize: 11,
),
),
if (camera != null)
Text(
'streaming:${camera.isStreaming} '
'camErr:${camera.errorDescription ?? ''}',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.cyanAccent,
fontSize: 11,
),
),
if (vm.state.debugLastError != null)
Text(
vm.state.debugLastError!,
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style:
const TextStyle(color: Colors.redAccent, fontSize: 11),
),
if (_globalError != null)
Text(
_globalError!,
maxLines: 3,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style:
const TextStyle(color: Colors.redAccent, fontSize: 11),
),
],
],
),
),
],
);
}
}
/// 双指捏合缩放预览;overlay 与纹理同几何(Stack 内同尺寸)
@@ -464,10 +606,7 @@ class _ZoomablePreview extends StatefulWidget {
/// 检测框 overlay(随帧更新,与纹理同区域)
final Widget? overlay;
const _ZoomablePreview({
required this.controller,
this.overlay,
});
const _ZoomablePreview({required this.controller, this.overlay});
@override
State<_ZoomablePreview> createState() => _ZoomablePreviewState();
@@ -497,8 +636,7 @@ class _ZoomablePreviewState extends State<_ZoomablePreview> {
return GestureDetector(
onScaleStart: (_) => _gestureStartZoom = _currentZoom,
onScaleUpdate: (d) {
final target =
(_gestureStartZoom * d.scale).clamp(_minZoom, _maxZoom);
final target = (_gestureStartZoom * d.scale).clamp(_minZoom, _maxZoom);
if ((target - _currentZoom).abs() < 0.01) return;
_currentZoom = target;
widget.controller.setZoomLevel(target);
@@ -518,10 +656,7 @@ class _CameraTopBar extends StatelessWidget {
final VoidCallback onClose;
final VoidCallback onOpenSettings;
const _CameraTopBar({
required this.onClose,
required this.onOpenSettings,
});
const _CameraTopBar({required this.onClose, required this.onOpenSettings});
@override
Widget build(BuildContext context) {
@@ -558,10 +693,7 @@ class _PermissionGuide extends StatelessWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'需要相机权限才能进行实时识别',
style: TextStyle(color: Colors.white),
),
const Text('需要相机权限才能进行实时识别', style: TextStyle(color: Colors.white)),
const SizedBox(height: 16),
FilledButton(onPressed: onRequest, child: const Text('授权相机')),
],
+66 -1
View File
@@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart';
import '../detection/detection_result.dart';
import '../detection/motion_aggregator.dart';
import '../detection/nms.dart';
import '../reminder/reminder.dart';
@immutable
@@ -21,6 +22,10 @@ class CameraUiState {
final int debugLastMs;
final String debugYuv;
/// 近 [CameraViewModel.latencyWindow] 帧推理耗时滚动平均(毫秒;无样本为 null)。
/// 给 C 端用户看的设备识别延迟。
final double? latencyAvgMs;
const CameraUiState({
this.modelReady = false,
this.results = const [],
@@ -34,6 +39,7 @@ class CameraUiState {
this.framesReceived = 0,
this.debugLastMs = 0,
this.debugYuv = '',
this.latencyAvgMs,
});
}
@@ -43,6 +49,9 @@ class CameraUiState {
/// 高于 0.35 视为强证据。
/// - 低于 0.35 的框:需要多帧稳定([confirmFrames] 帧)或 活动证据
/// (运动区域/背景新出现区域重叠)才确认显示。
/// - 同位置去重(2026-09-03):多模型对同一目标交替检出时各轨迹都会在
/// 忘记窗内持续显示 → 轨迹层跨标签同目标补挂 + 显示层
/// [dedupeVisibleOverlaps] 同类别强重叠只保留高置信度框。
class CameraViewModel extends ChangeNotifier {
static const int maxTracks = 30;
static const double motionBoost = 0.15;
@@ -52,6 +61,10 @@ class CameraViewModel extends ChangeNotifier {
static const int displayAgeMs = 500;
static const int forgetMs = 2000;
/// 延迟滚动平均窗口(帧数):单帧抖动大,取近期均值给用户展示
static const int latencyWindow = 30;
final List<int> _procWindow = [];
/// 推理后台 isolate 是否就绪(由相机页创建 worker 后设置)
bool modelReady = false;
@@ -68,6 +81,8 @@ class CameraViewModel extends ChangeNotifier {
void setModelReady(bool ready) {
if (modelReady == ready) return;
modelReady = ready;
// 换 worker / 停识别:旧窗口样本作废,从零起算
_procWindow.clear();
_state = CameraUiState(modelReady: ready);
notifyListeners();
}
@@ -122,9 +137,18 @@ class CameraViewModel extends ChangeNotifier {
for (final r in results) {
if (r.score > highest) highest = r.score;
}
if (lastProcessMs > 0) {
_procWindow.add(lastProcessMs);
if (_procWindow.length > latencyWindow) {
_procWindow.removeAt(0);
}
}
final latencyAvg = _procWindow.isEmpty
? null
: _procWindow.reduce((a, b) => a + b) / _procWindow.length;
_state = CameraUiState(
modelReady: modelReady,
results: visible,
results: dedupeVisibleOverlaps(visible),
rotation: rotation,
imageWidthPx: imageWidthPx,
imageHeightPx: imageHeightPx,
@@ -135,6 +159,7 @@ class CameraViewModel extends ChangeNotifier {
framesReceived: framesReceived,
debugLastMs: lastProcessMs,
debugYuv: yuvDiag.isNotEmpty ? yuvDiag : _state.debugYuv,
latencyAvgMs: latencyAvg,
);
notifyListeners();
}
@@ -161,6 +186,24 @@ class CameraViewModel extends ChangeNotifier {
best = t;
}
}
// 跨标签同目标补挂(2026-09-03 用户实测修订:重叠位置只保留高置信度框):
// 中心距超过跨标签收紧半径、但几何强重叠指向同一位置(不同模型对同一
// 目标的框偏移/紧致度不同)的检测并入既有轨迹,防止同目标两条轨迹并存
// → 同位置双名常驻/重复提醒。疑似↔目标(不同类别预警)不并入。
if (best == null) {
_Track? adopt;
var adoptD = double.infinity;
for (final t in _tracks.values) {
if (matched.contains(t.id)) continue;
if (t.isSuspect != r.isSuspect || !sameTarget(t.result, r)) continue;
final d = _centerDist(t.result, r);
if (d < adoptD) {
adoptD = d;
adopt = t;
}
}
best = adopt;
}
if (best != null) {
matched.add(best.id);
best.update(r, now);
@@ -234,8 +277,30 @@ class _Track {
: lastSeenMs = firstSeenMs,
label = result.label;
/// 当前框是否疑似类别(随关联的最新检测更新——轨迹框在跨标签补挂后
/// 会换成其他标签的框;轨迹 label 仅创建时记账)
bool get isSuspect => result.isSuspect;
void update(DetectionResult r, int now) {
lastSeenMs = now;
result = r;
}
}
/// 同屏抑制(2026-09-03 用户实测修订:同一位置/重叠位置出现同/异模型检出的
/// 物种只保留高置信度者)。根因:多个模型对同一目标**交替**检出时,每帧合并
/// 层只压掉当帧低分框,但各自轨迹都落在 2s 忘记窗内持续显示 → 同位置双名
/// 常驻。显示层兜底:可见框内同类别(都目标/都疑似)、且 [sameTarget] 强
/// 重叠指向同一位置的框每帧只保留最高分者。异类别(目标×疑似生境预警)是
/// 两种语义不同的框,同位置也各自保留。
List<DetectionResult> dedupeVisibleOverlaps(List<DetectionResult> visible) {
if (visible.length <= 1) return visible;
final sorted = [...visible]..sort((a, b) => b.score.compareTo(a.score));
final kept = <DetectionResult>[];
for (final r in sorted) {
if (!kept.any((k) => k.isSuspect == r.isSuspect && sameTarget(k, r))) {
kept.add(r);
}
}
return kept;
}
+22 -6
View File
@@ -12,8 +12,12 @@ class FrameAnalyzer {
/// 连续检测:100ms 一帧
int intervalMs = 100;
/// null = 模型加载失败,仅预览不分析
final DetectorWorker? worker;
DetectorWorker? _worker;
/// 当前推理 worker(null = 仅预览不分析);相机启动后可用 [attachWorker]
/// 原地替换(相机帧流回调闭包捕获的是本 analyzer 对象,无需重启相机)
DetectorWorker? get worker => _worker;
final CameraViewModel viewModel;
int _lastDetectMs = 0;
@@ -29,9 +33,21 @@ class FrameAnalyzer {
/// 图像流回调是否到达(诊断用)
int framesReceived = 0;
FrameAnalyzer({required this.worker, required this.viewModel}) {
worker?.onResult = _onResult;
worker?.onError = _onError;
FrameAnalyzer({DetectorWorker? worker, required this.viewModel}) {
attachWorker(worker);
}
/// 替换推理 workernull = 停识别仅预览)。旧 worker 在此释放;若相机已
/// 启动则新 worker 立即接管后续帧——重启相机会重新 initialize
/// (iOS ~1s+)且预览闪断,原地替换即时生效(2026-09-03)。
void attachWorker(DetectorWorker? w) {
if (identical(_worker, w)) return;
final old = _worker;
_worker = w;
w?.onResult = _onResult;
w?.onError = _onError;
old?.dispose();
_lastDetectMs = 0;
}
void recordStreamError(String msg) {
@@ -127,5 +143,5 @@ class FrameAnalyzer {
void reset() => _lastDetectMs = 0;
void dispose() => worker?.dispose();
void dispose() => _worker?.dispose();
}
+315 -204
View File
@@ -2,15 +2,24 @@ import 'package:flutter/material.dart';
import '../models/model_manager.dart';
/// 设置弹层「模型清单」区块:顶部「识别模式」分段控件(s 高识别 / n 高性能,
/// 默认 s,持久化本地;切换即热加载该档位已激活模型),下方 2 列封面缩略图网格。
/// 双档位:每数据集至多两张卡片(s/n 各一),卡片带档位角标;
/// 卡片状态(下载进度/激活)按 (数据集, 档位) 独立记账。
/// 档位展示名(档位码 s/n 仅内部记账,不对用户展示)
String _variantLabel(String v) => v == kVariantN ? '高性能' : '高精度';
/// 设置弹层「模型清单」区块:顶部「识别模式」分段控件(高性能 / 高精度,
/// 默认高精度,持久化本地;2026-09-03 高性能移左位)选择**目标档位**——
/// 只记录偏好,不直接切换运行中的模型,而是决定卡片按钮面向哪个档;下方
/// 2 列封面缩略图网格,同一物种(datasetId)合并一张卡(s/n 两档内部记账,
/// 各自下载独立进度)。
///
/// 卡片状态机(档位 == 当前识别档位时与原单档行为一致):
/// - 未下载 →「使用/下载」:下载完成自动激活(当前档立即使用,非当前档备好待切);
/// - 已下载未激活 →「使用」直接激活;已激活且当前档 →「已使用」点击取消;
/// - 已激活但属非当前档 →「已备好」(已下载就绪,点击切换到该档立即生效)。
/// 同一物种一次只运行一个档位:启用某档会自动停用同物种另一档(不同物种可用
/// 不同档位并行识别)。物种卡片主按钮(面向目标档,2026-09-03 简化文案——
/// 一律「使用/使用中/下载」,不再叫「改用X」,不显示当前运行档提示):
/// - 任一档下载中 → 逐档进度条 + 取消(中止全部进行中的下载);
/// - 任一档失败 → 错误提示 + 重试(只补下未成功的档);
/// - 目标档已激活 →「使用中」点击取消使用;
/// - 目标档已下载未激活 →「使用」直接启用(另一档在运行会被自动停用);
/// - 目标档未下载 →「下载」取回缺失档(目标档落地自动启用、伴档备好;
/// 另一档在使用时补下目标档后自动切换过去)。
class ModelCatalogSection extends StatelessWidget {
final ModelManager manager;
@@ -21,44 +30,57 @@ class ModelCatalogSection extends StatelessWidget {
return ListenableBuilder(
listenable: manager,
builder: (context, _) {
// 目录行按 (数据集, 档位) 稳定排序:同数据集 s 前 n 后相邻展示
final items = [...manager.catalog]..sort((a, b) {
if (a.datasetId != b.datasetId) {
return a.datasetId.compareTo(b.datasetId);
}
return a.variant.compareTo(b.variant);
});
// 目录按数据集分组:同一物种 s/n 合成一张卡;组内 s(高精度)前 n 后
final byDataset = <int, List<ModelCatalogItem>>{};
for (final c in manager.catalog) {
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};
for (final g in groups) {
g.sort((a, b) =>
(order[a.variant] ?? 9).compareTo(order[b.variant] ?? 9));
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
const Text('识别模式',
style: TextStyle(color: Colors.white70, fontSize: 14)),
const Text(
'识别模式',
style: TextStyle(color: Colors.white70, fontSize: 14),
),
const Spacer(),
_ModeToggle(manager: manager),
],
),
const SizedBox(height: 2),
const Text('切换即加载该档位已激活模型(激活状态跨档保留)',
style: TextStyle(color: Colors.white38, fontSize: 11)),
const Text(
'卡片操作面向所选档位;同一动物一次只运行一档,切换会自动停用另一档',
style: TextStyle(color: Colors.white38, fontSize: 11),
),
const SizedBox(height: 12),
Row(
children: [
const Text('模型清单',
style: TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.bold)),
const Text(
'模型清单',
style: TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
TextButton.icon(
onPressed: () => manager.refresh(),
icon: const Icon(Icons.refresh, size: 16),
label: const Text('刷新'),
style: TextButton.styleFrom(
foregroundColor: Colors.white70,
visualDensity: VisualDensity.compact),
foregroundColor: Colors.white70,
visualDensity: VisualDensity.compact,
),
),
],
),
@@ -66,27 +88,29 @@ class ModelCatalogSection extends StatelessWidget {
if (manager.error != null)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(manager.error!,
style: const TextStyle(
color: Colors.orange, fontSize: 12)),
child: Text(
manager.error!,
style: const TextStyle(color: Colors.orange, fontSize: 12),
),
),
if (items.isEmpty)
const Text('暂无已发布模型',
style: TextStyle(color: Colors.white54, fontSize: 13))
if (groups.isEmpty)
const Text(
'暂无已发布模型',
style: TextStyle(color: Colors.white54, fontSize: 13),
)
else
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 0.72,
),
itemCount: items.length,
itemCount: groups.length,
itemBuilder: (context, i) =>
_ModelCard(item: items[i], manager: manager),
_SpeciesCard(items: groups[i], manager: manager),
),
],
);
@@ -95,12 +119,16 @@ class ModelCatalogSection extends StatelessWidget {
}
}
/// 档位分段控件:s 高识别 / n 高性能
/// 目标档位分段控件:高性能 / 高精度(2026-09-03 调换两档显示位置,
/// 高性能在左、高精度在右;档位码 s/n 仅内部使用,不对用户展示)
class _ModeToggle extends StatelessWidget {
final ModelManager manager;
const _ModeToggle({required this.manager});
/// 展示顺序(与内部档位常量解耦)
static const List<String> _displayOrder = [kVariantN, kVariantS];
@override
Widget build(BuildContext context) {
return Container(
@@ -112,9 +140,9 @@ class _ModeToggle extends StatelessWidget {
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
for (final v in const [kVariantS, kVariantN]) ...[
if (v != kVariantS) const SizedBox(width: 2),
_seg(v),
for (var i = 0; i < _displayOrder.length; i++) ...[
if (i > 0) const SizedBox(width: 2),
_seg(_displayOrder[i]),
],
],
),
@@ -123,7 +151,6 @@ class _ModeToggle extends StatelessWidget {
Widget _seg(String v) {
final selected = manager.mode == v;
final label = v == kVariantS ? 's 高识别' : 'n 高性能';
return InkWell(
borderRadius: BorderRadius.circular(6),
onTap: () => manager.setMode(v),
@@ -134,7 +161,7 @@ class _ModeToggle extends StatelessWidget {
borderRadius: BorderRadius.circular(6),
),
child: Text(
label,
_variantLabel(v),
style: TextStyle(
color: selected ? Colors.black : Colors.white70,
fontSize: 12,
@@ -146,22 +173,35 @@ class _ModeToggle extends StatelessWidget {
}
}
class _ModelCard extends StatelessWidget {
final ModelCatalogItem item;
/// 单物种卡片:同数据集 s/n 两档合并;下载/激活状态按 (数据集, 档位) 独立记账,
/// 同物种同时至多一个档位被激活(激活以目标档为准)
class _SpeciesCard extends StatelessWidget {
final List<ModelCatalogItem> items;
final ModelManager manager;
const _ModelCard({required this.item, required this.manager});
const _SpeciesCard({required this.items, required this.manager});
@override
Widget build(BuildContext context) {
final variant = item.variant;
final sameMode = manager.mode == variant;
final active = manager.isActive(item.datasetId, variant);
final downloaded = manager.isDownloaded(item.datasetId, variant);
final progress = manager.progressOf(item.datasetId, variant);
final error = manager.errorOf(item.datasetId, variant);
final accent =
variant == kVariantS ? Colors.greenAccent : Colors.orangeAccent;
// 目标条目:当前目标档优先;目录缺目标档(存量单档物种)时取 s 档
final target = items.firstWhere(
(i) => i.variant == manager.mode,
orElse: () => items.first,
);
final tActive = manager.isActive(target.datasetId, target.variant);
final tDownloaded = manager.isDownloaded(target.datasetId, target.variant);
// 同物种另一档正在使用的条目(至多一个——每数据集单档激活约束)
final activeOther = items
.where((i) =>
i.variant != target.variant &&
manager.isActive(i.datasetId, i.variant))
.toList();
// 任一档进行中/失败标志
final downloading =
items.any((i) => manager.progressOf(i.datasetId, i.variant) != null);
final hasError =
items.any((i) => manager.errorOf(i.datasetId, i.variant) != null);
final thumb = ClipRRect(
borderRadius: BorderRadius.circular(8),
@@ -171,41 +211,25 @@ class _ModelCard extends StatelessWidget {
fit: StackFit.expand,
children: [
Image.network(
'${manager.baseUrl}${item.coverUrl}',
'${manager.baseUrl}${target.coverUrl}',
fit: BoxFit.cover,
loadingBuilder: (context, child, chunk) => chunk == null
? child
: Container(
color: Colors.white12,
child: const Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2)))),
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
),
errorBuilder: (context, error, stack) => Container(
color: Colors.white12,
child: const Icon(Icons.image_not_supported_outlined,
color: Colors.white38),
),
),
// 档位角标:s 高识别(绿)/ n 高性能(橙)
Positioned(
top: 4,
left: 4,
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
decoration: BoxDecoration(
color: accent,
borderRadius: BorderRadius.circular(4),
),
child: Text(
variant == kVariantS ? 's' : 'n',
style: const TextStyle(
color: Colors.black87,
fontSize: 10,
fontWeight: FontWeight.bold),
child: const Icon(
Icons.image_not_supported_outlined,
color: Colors.white38,
),
),
),
@@ -214,110 +238,6 @@ class _ModelCard extends StatelessWidget {
),
);
Widget action;
if (progress != null) {
action = Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
LinearProgressIndicator(
value: progress,
backgroundColor: Colors.white12,
color: Colors.greenAccent),
const SizedBox(height: 2),
Row(
children: [
Expanded(
child: Text(
'${(progress * 100).toStringAsFixed(0)}%',
textAlign: TextAlign.center,
style:
const TextStyle(color: Colors.white70, fontSize: 11),
),
),
TextButton(
onPressed: () =>
manager.cancelDownload(item.datasetId, variant),
style: TextButton.styleFrom(
foregroundColor: Colors.white54,
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.symmetric(horizontal: 8),
minimumSize: const Size(0, 24),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: const Text('取消', style: TextStyle(fontSize: 11)),
),
],
),
],
);
} else if (error != null) {
action = Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(error,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.redAccent, fontSize: 10)),
TextButton(
onPressed: () => manager.downloadModel(item),
child: const Text('重试', style: TextStyle(fontSize: 12)),
),
],
);
} else if (active && downloaded) {
if (sameMode) {
// 当前档已激活:点击取消
action = SizedBox(
height: 30,
child: OutlinedButton(
onPressed: () =>
manager.setActive(item.datasetId, variant, false),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.greenAccent,
side: const BorderSide(color: Colors.greenAccent)),
child: const Text('已使用', style: TextStyle(fontSize: 12)),
),
);
} else {
// 非当前档已备好(激活保留):点击切换到该档立即生效
action = SizedBox(
height: 30,
child: OutlinedButton(
onPressed: () => manager.setMode(variant),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.amberAccent,
side: const BorderSide(color: Colors.amberAccent)),
child: const Text('已备好', style: TextStyle(fontSize: 12)),
),
);
}
} else if (downloaded) {
action = SizedBox(
height: 30,
child: FilledButton(
onPressed: () => manager.setActive(item.datasetId, variant, true),
style: FilledButton.styleFrom(
backgroundColor: Colors.greenAccent,
foregroundColor: Colors.black,
visualDensity: VisualDensity.compact),
child: const Text('使用', style: TextStyle(fontSize: 12)),
),
);
} else {
action = SizedBox(
height: 30,
child: FilledButton(
onPressed: () => manager.downloadModel(item),
style: FilledButton.styleFrom(
backgroundColor: Colors.greenAccent,
foregroundColor: Colors.black,
visualDensity: VisualDensity.compact),
child: Text(sameMode ? '使用' : '下载', style: const TextStyle(fontSize: 12)),
),
);
}
return Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
@@ -332,35 +252,226 @@ class _ModelCard extends StatelessWidget {
Row(
children: [
Expanded(
child: Text(item.datasetName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w600)),
),
Container(
padding:
const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration(
color: accent.withValues(alpha: 0.25),
borderRadius: BorderRadius.circular(4),
),
child: Text(
variant == kVariantS ? '高识别' : '高性能',
style: TextStyle(color: accent, fontSize: 9),
target.datasetName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
),
],
),
const SizedBox(height: 2),
Text('v${item.version}',
style: const TextStyle(color: Colors.white38, fontSize: 10)),
// 双档行:高精度 / 高性能各自版本号;使用中绿色高亮、已下载次之、缺失置灰
Text.rich(
TextSpan(
children: [
for (final i in items) ...[
if (i != items.first) const TextSpan(text: ' · '),
TextSpan(
text: '${_variantLabel(i.variant)} ${i.version}',
style: TextStyle(
fontSize: 9,
color: manager.isActive(i.datasetId, i.variant)
? Colors.greenAccent
: manager.isDownloaded(i.datasetId, i.variant)
? Colors.white70
: Colors.white38,
fontWeight: manager.isActive(i.datasetId, i.variant)
? FontWeight.bold
: FontWeight.normal,
),
),
],
],
),
),
const SizedBox(height: 6),
action,
_actionArea(
items: items,
target: target,
downloading: downloading,
hasError: hasError,
tActive: tActive,
tDownloaded: tDownloaded,
activeOther: activeOther,
),
],
),
);
}
/// 动作区:进度/错误优先级最高;其次按目标档的下载/激活状态给主按钮
Widget _actionArea({
required List<ModelCatalogItem> items,
required ModelCatalogItem target,
required bool downloading,
required bool hasError,
required bool tActive,
required bool tDownloaded,
required List<ModelCatalogItem> activeOther,
}) {
if (downloading) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final i in items)
if (manager.progressOf(i.datasetId, i.variant) != null)
_progressRow(item: i),
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () {
for (final i in items) {
if (manager.progressOf(i.datasetId, i.variant) != null) {
manager.cancelDownload(i.datasetId, i.variant);
}
}
},
style: TextButton.styleFrom(
foregroundColor: Colors.white54,
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.symmetric(horizontal: 8),
minimumSize: const Size(0, 24),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: const Text('取消', style: TextStyle(fontSize: 11)),
),
),
],
);
}
if (hasError) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final i in items)
if (manager.errorOf(i.datasetId, i.variant) != null)
Text(
'${_variantLabel(i.variant)}下载失败',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.redAccent, fontSize: 10),
),
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () {
for (final i in items) {
if (manager.errorOf(i.datasetId, i.variant) != null) {
manager.downloadModel(i);
}
}
},
style: TextButton.styleFrom(
foregroundColor: Colors.redAccent,
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.symmetric(horizontal: 8),
minimumSize: const Size(0, 24),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: const Text('重试', style: TextStyle(fontSize: 11)),
),
),
],
);
}
final Widget mainBtn;
if (tActive) {
// 目标档使用中:点击取消使用
mainBtn = SizedBox(
height: 30,
child: OutlinedButton(
onPressed: () =>
manager.setActive(target.datasetId, target.variant, false),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.greenAccent,
side: const BorderSide(color: Colors.greenAccent),
),
child: const Text('使用中', style: TextStyle(fontSize: 12)),
),
);
} else if (tDownloaded) {
// 已下载未激活(目标档):点「使用」直接启用——同物种另一档在使用会被
// 自动停用(每数据集至多一档运行),统一文案不再叫「改用X」(2026-09-03
mainBtn = SizedBox(
height: 30,
child: FilledButton(
onPressed: () =>
manager.setActive(target.datasetId, target.variant, true),
style: FilledButton.styleFrom(
backgroundColor: Colors.greenAccent,
foregroundColor: Colors.black,
visualDensity: VisualDensity.compact,
),
child: const Text('使用', style: TextStyle(fontSize: 12)),
),
);
} else {
// 目标档未下载:下载全部缺失档(目标档落地自动启用、伴档备好待切);
// 另一档在使用时按钮同样为「下载」:补下目标档后自动切换(停用另一档)
final use = activeOther.isNotEmpty;
mainBtn = SizedBox(
height: 30,
child: FilledButton(
onPressed: () async {
if (!use) {
for (final i in items) {
if (!manager.isDownloaded(i.datasetId, i.variant)) {
manager.downloadModel(i);
}
}
return;
}
if (!manager.isDownloaded(target.datasetId, target.variant)) {
final ok = await manager.downloadModel(target);
if (!ok) return; // 下载失败/取消:错误分支展示,保持现状
}
await manager.setActive(target.datasetId, target.variant, true);
},
style: FilledButton.styleFrom(
backgroundColor: Colors.greenAccent,
foregroundColor: Colors.black,
visualDensity: VisualDensity.compact,
),
child: const Text('下载', style: TextStyle(fontSize: 12)),
),
);
}
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [mainBtn],
);
}
Widget _progressRow({required ModelCatalogItem item}) {
final p = manager.progressOf(item.datasetId, item.variant) ?? 0.0;
final label = _variantLabel(item.variant);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Text(
'$label ${(p * 100).toStringAsFixed(0)}%',
style: const TextStyle(color: Colors.white70, fontSize: 10),
),
],
),
LinearProgressIndicator(
value: p,
backgroundColor: Colors.white12,
color: Colors.greenAccent,
),
],
);
}
}
@@ -34,6 +34,9 @@ class DetectionResult {
double get centerX => (left + right) / 2;
double get centerY => (top + bottom) / 2;
/// 疑似(生境预警)类别:类别索引 >0 或训练标签为 suspect
bool get isSuspect => classId > 0 || label == 'suspect';
DetectionResult copyWith({
double? score,
double? left,
+20 -6
View File
@@ -229,15 +229,12 @@ Future<void> _workerMain(SendPort mainPort) async {
for (final entry in list[1] as List) {
final e = entry as List;
final name = e.length > 3 ? e[3] as String : '';
// 模型名带档位标识(数据集名+档位,框来源可辨 s/n
final variant = e.length > 4 ? e[4] as String : '';
final displayName =
variant.isEmpty ? name : '$name($variant)';
// modelName 仅标注来源数据集名档位码 s/n 内部记账,不展示给用户
final d = await TfliteDetector.fromBuffer(
e[0] as Uint8List,
(e[1] as List).cast<String>(),
modelId: (e[2] as num).toInt(),
modelName: displayName,
modelName: name,
);
if (d == null) {
failures.add(name.isEmpty ? 'unknown' : name);
@@ -475,8 +472,25 @@ Future<void> _workerMain(SendPort mainPort) async {
/// 实测多个模型会对同一目标检出不同类别(误检/歧义),若异类别互不压制
/// 会出现重叠框;2026-09-01 用户实测定案:所有模型的框统一按 IoU 去重,
/// 重叠时取高分(远处真实的多目标互不重叠,正常保留)。
///
/// 2026-09-03 补跨模型同目标窗口:小框被大框覆盖 > [iouThreshold] 直接去重;
/// 覆盖不足、但两框来自**不同模型**且 [sameTarget](小框中心在大框内、
/// 覆盖 ≥ 30%)也去重——不同输入分辨率模型对同一目标的框几何有系统性偏移,
/// 纯阈值会漏判。同模型框对(模型内已做过类内 NMS)不套用该窗口。
List<DetectionResult> mergeAcrossModels(
List<DetectionResult> all, double iouThreshold) {
if (all.length <= 1) return all;
return nms(all, iouThreshold);
final sorted = [...all]..sort((a, b) => b.score.compareTo(a.score));
final kept = <DetectionResult>[];
for (final b in sorted) {
final dup = kept.any((k) {
if (boxOverlap(k, b) > iouThreshold) return true;
if (k.modelId == b.modelId || k.modelId < 0 || b.modelId < 0) {
return false; // 同模型/无来源:类内 NMS 已处理,不补窗
}
return sameTarget(k, b);
});
if (!dup) kept.add(b);
}
return kept;
}
+19
View File
@@ -35,3 +35,22 @@ List<DetectionResult> nms(List<DetectionResult> boxes, double iouThreshold) {
}
return kept;
}
/// 同目标判定(2026-09-03 用户实测修订:同一标注位置/重叠位置,同/异模型
/// 检出的物种只保留高置信度框)。不同模型对同一目标的框紧致度/偏移系统性
/// 不同,纯 boxOverlap 阈值(0.45)会漏判「几何明显指向同一位置」的偏移框;
/// 补判条件:小框被大框覆盖 ≥ [sameTargetMinCover] 且小框中心落在大框内
/// (相邻独立目标的中心不会落在对方框内,不会被误并)。
const double sameTargetMinCover = 0.3;
bool sameTarget(DetectionResult a, DetectionResult b) {
final cover = boxOverlap(a, b);
if (cover < sameTargetMinCover) return false;
final aBigger = a.width * a.height >= b.width * b.height;
final big = aBigger ? a : b;
final small = aBigger ? b : a;
return small.centerX >= big.left &&
small.centerX <= big.right &&
small.centerY >= big.top &&
small.centerY <= big.bottom;
}
@@ -11,7 +11,7 @@ import 'nms.dart';
/// 模型输出布局(ultralytics litert 导出):[1, 4 + nc, anchors]
/// cx/cy/w/h 已归一化,类别得分已过 sigmoid;按 out[dim][anchor] 索引。
/// 输入为 NCHW [1, 3, H, W]litert 导出保留 torch 布局),H/W 随模型档位:
/// s 高识别 @1280、n 高性能 @704,输入尺寸取自模型自身。
/// s 高精度 @1280、n 高性能 @704,输入尺寸取自模型自身。
class TfliteDetector {
// 输入尺寸取自模型本身(ultralytics litert 导出 NCHW [1,3,H,W],各数据集
// 训练 imgsz 可不同),默认 1280 兜底
+157 -102
View File
@@ -9,7 +9,7 @@ import 'package:path_provider/path_provider.dart';
import '../config/app_config.dart';
/// 识别档位标识:s = 高识别(@1280 精度优先,默认),n = 高性能(@704 速度优先)
/// 识别档位标识:s = 高精度(@1280 精度优先,默认),n = 高性能(@704 速度优先)
const String kVariantS = 's';
const String kVariantN = 'n';
@@ -85,8 +85,17 @@ class ModelBundle {
/// 档位标识符的「无子目录」形态,存量设备无需迁移),n 档存 `models/<datasetId>/n/`
/// 各目录含 model.tflite + labels.json + meta.jsonmeta 记录 {version, sha256}
/// 版本与摘要都未变化时跳过下载。记账键一律是 (datasetId, variant) 二元组。
/// 激活集与识别档位无关(跨档保持);推理加载「当前档位」下全部激活模型,
/// 切档即热加载新档位已激活模型([mode] 持久化本地)。
/// 识别目标档位 [mode] 只是用户偏好(持久化):设置弹层卡片按钮面向该档位。
/// 实际运行由激活集驱动——每个数据集**至多一个档位**在使用:激活某档会自动停用
/// 同数据集另一档,不同数据集可用不同档位并行识别(2026-09-03 修订)。
/// 激活集是**会话态**2026-09-03 修订):每次进入视野页 [resetForSession] 清空、
/// 不跨会话持久化——识别需用户在模型清单手动启用(显式「下载」落地即启用目标档
/// 属于用户动作);上次崩溃/坏模型不会在下次打开时自动复现,用户总能看到仅预览
/// 界面并自行调整。
/// 目录**缓存优先**2026-09-03):最近一次成功拉取的 models 目录落盘
/// catalog.json[refresh] 开头先载入缓存并通知(弹层离线也有内容展示),网络
/// 成功后再以权威目录覆盖并落盘;清理/激活同步/自动更新只在网络成功(fetched)
/// 后执行——缓存降级时不清文件不下载,离线首启不误删已下载模型。
class ModelManager extends ChangeNotifier {
static final ModelManager instance = ModelManager._();
@@ -103,7 +112,6 @@ class ModelManager extends ChangeNotifier {
final Set<ModelKey> _cancelRequested = {};
String _mode = kVariantS;
bool _modeLoaded = false;
bool _activeLoaded = false;
bool _ready = false;
bool _refreshing = false;
String? _error;
@@ -117,7 +125,8 @@ class ModelManager extends ChangeNotifier {
/// 服务器目录(弹层模型清单展示用;同一数据集可能 s/n 两行)
List<ModelCatalogItem> get catalog => _catalog;
/// 当前识别档位(s 高识别默认 / n 高性能),持久化,切档即热加载新档位模型
/// 识别目标档位(默认 s 高精度):用户偏好,持久化;只决定卡片按钮与
/// 首次下载的自动激活档,不直接切换已在运行的模型(运行看激活集)
String get mode => _mode;
bool isActive(int datasetId, String variant) =>
@@ -154,7 +163,7 @@ class ModelManager extends ChangeNotifier {
_client = client ?? http.Client(),
_rootDirOverride = rootDir;
/// 已激活且已下载、且属于当前档位的模型列表(空 = 未加载任何模型,仅预览)
/// 已激活且已下载的模型列表(每个数据集至多一个档位;空 = 未加载任何模型,仅预览)
List<ModelBundle> get models => _models;
/// 是否成功拉取过目录(即使下载失败也为 true,用于区分"从未联网"与"目录为空"
@@ -165,20 +174,19 @@ class ModelManager extends ChangeNotifier {
bool get refreshing => _refreshing;
/// 模型名摘要(诊断行展示):数据集名+档位
/// 模型名摘要(诊断行展示):数据集名(同数据集的档位码不外显)
String get modelsLabel {
if (_models.isEmpty) return '未下载';
return _models.map((m) => '${m.datasetName}(${m.variant})').join(',');
return _models.map((m) => m.datasetName).join(',');
}
/// 切换识别档位:持久化本地并热加载该档位已激活模型(激活集跨档保留)
/// 切换识别目标档位:只改偏好并持久化(不切换已在运行的模型——
/// 每个动物的实际档位由激活集决定,卡片按钮会面向新目标档给出「改用」操作)
Future<void> setMode(String variant) async {
if (variant != kVariantS && variant != kVariantN) return;
if (_mode == variant) return;
_mode = variant;
await _saveMode();
_models = await _loadBundles(_catalog);
_revision++;
notifyListeners();
}
@@ -194,26 +202,55 @@ class ModelManager extends ChangeNotifier {
return _inFlight!;
}
/// 开始新识别会话(进入视野页时调用):清空激活集与已加载模型。
/// 激活集为会话态、不做跨会话持久化——上次使用的模型不自动恢复,识别需
/// 用户在模型清单手动启用(2026-09-03 会话制修订)。
void resetForSession() {
if (_active.isEmpty) return;
_active.clear();
_models = const [];
_revision++;
notifyListeners();
}
Future<void> _doRefresh() async {
try {
await _loadActive();
await _loadMode();
final res = await _client
.get(Uri.parse('$baseUrl/api/v1/app/update'))
.timeout(const Duration(seconds: 30));
// 服务器 Content-Type 无 charsethttp 包默认按 latin1 解码会乱码 → 显式 utf8
final body =
jsonDecode(utf8.decode(res.bodyBytes)) as Map<String, dynamic>;
final data = body['data'] as Map<String, dynamic>? ?? const {};
final list = data['models'] as List? ?? const [];
_catalog = list
.map((e) => ModelCatalogItem.fromJson(e as Map<String, dynamic>))
.toList();
// 缓存优先(2026-09-03):网络返回前先载入上次成功拉取的目录并提前 notify
// ——设置弹层打开即有内容展示,不依赖网络请求;网络成功后再以权威目录覆盖
if (_catalog.isEmpty) {
await _loadCatalogCache();
if (_catalog.isNotEmpty) notifyListeners();
}
var fetched = false;
try {
final res = await _client
.get(Uri.parse('$baseUrl/api/v1/app/update'))
.timeout(const Duration(seconds: 30));
// 服务器 Content-Type 无 charsethttp 包默认按 latin1 解码会乱码 → 显式 utf8
final body =
jsonDecode(utf8.decode(res.bodyBytes)) as Map<String, dynamic>;
final data = body['data'] as Map<String, dynamic>? ?? const {};
final list = data['models'] as List? ?? const [];
_catalog = list
.map((e) => ModelCatalogItem.fromJson(e as Map<String, dynamic>))
.toList();
await _saveCatalogCache(list);
fetched = true;
} catch (e) {
// 拉取失败:保留缓存/旧目录继续展示;本实例从未拉取成功过才记错误
// (有缓存兜底时同样提示,说明当前展示的目录未经最新网络确认)
if (!_ready) _error = '模型目录拉取失败:$e';
}
// 无缓存且未拉取成功(目录确为空):无从同步,等下次刷新
if (_catalog.isEmpty && !fetched) return;
// 只拉目录不下载;扫描本地已下载(meta+文件齐备)供清单展示
// 只拉目录不下载;扫描本地已有模型文件供清单展示。版本新旧都算已下载:
// 旧版本文件由 autoUpdate 静默补齐,无需用户看到「下载」按钮再下。
// 缓存目录同样扫描:离线重开也能正确标出已下载档位
final downloaded = <ModelKey>{};
for (final item in _catalog) {
if (await _isLocal(item)) {
if (await _hasFile(item)) {
downloaded.add((datasetId: item.datasetId, variant: item.variant));
}
}
@@ -221,18 +258,15 @@ class ModelManager extends ChangeNotifier {
..clear()
..addAll(downloaded);
// 清理/激活同步/自动更新只认网络拉到的权威目录:缓存降级时不清文件、
// 不触发下载——离线首启不会误删已下载模型(2026-09-03)
if (!fetched) return;
await _prune(_catalog);
// 服务器已下线的 (数据集, 档位) 移出激活集
final catalogKeys = _catalog
.map((c) => (datasetId: c.datasetId, variant: c.variant))
.toSet();
final pruned = _active.where((k) => !catalogKeys.contains(k)).toList();
if (pruned.isNotEmpty) {
for (final k in pruned) {
_active.remove(k);
}
await _saveActive();
}
_active.removeWhere((k) => !catalogKeys.contains(k));
_models = await _loadBundles(_catalog);
_ready = true;
@@ -260,11 +294,14 @@ class ModelManager extends ChangeNotifier {
}
}
/// 按需下载并激活:流式下载 + sha256 校验 + 落盘(labels/meta
/// 成功自动加入激活集(下载完成即使用;非当前档位则备好待切档)。
/// 失败重试一次并记录错误。
/// 按需下载:流式下载 + sha256 校验 + 落盘(labels/meta
/// [autoActivate](默认 true,用户显式下载)该数据集此前无任何档位在使用且
/// 本档为目录中唯一可选/匹配目标档时自动激活(下载即有识别);
/// autoUpdate 等后台补档传 false:只更新文件不改变激活状态。
/// 使用中的档位原地更新则字节生效(重建推理 worker)。失败重试一次并记录错误。
Future<bool> downloadModel(ModelCatalogItem item,
{void Function(int received, int total)? onProgress}) async {
{bool autoActivate = true,
void Function(int received, int total)? onProgress}) async {
final key = (datasetId: item.datasetId, variant: item.variant);
// 并发保护:同一 (数据集, 档位) 已有进行中的下载则直接短路
if (_progress.containsKey(key)) return false;
@@ -284,14 +321,25 @@ class ModelManager extends ChangeNotifier {
if (ok) {
_progress.remove(key);
_errors.remove(key);
final wasActive = _active.contains(key);
_downloaded.add(key);
if (item.variant == _mode) {
// 字节替换生效(当前档位模型更新需重建 worker 读新文件
if (wasActive) {
// 使用中的模型原地更新:字节已替换,重建 worker 读新文件
_revision++;
_models = await _loadBundles(_catalog);
} else if (autoActivate &&
!_active.any((k) => k.datasetId == item.datasetId)) {
// 用户显式下载且该数据集尚无档位在使用:自动激活目标档条目;
// 目录没有目标档(存量单档物种)时激活本条,保证下载即有识别
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);
}
}
_models = await _loadBundles(_catalog);
notifyListeners();
await setActive(item.datasetId, item.variant, true);
return true;
}
if (_cancelRequested.contains(key)) break;
@@ -423,84 +471,49 @@ class ModelManager extends ChangeNotifier {
}
}
/// 设置激活状态(true=使用,false=取消);持久化到 `root/active.json`
/// 激活跨档位保留(切档后仍生效);仅影响当前档位加载时才需重建推理 worker。
/// 未下载的模型不可激活(下载完成由 downloadModel 自动激活)。
/// 设置激活状态(true=使用,false=取消;仅本次会话内生效,不持久化)
/// 同一数据集至多一个档位在使用:激活某档时若同数据集另一档在使用则先停用
/// (2026-09-03:不同动物可跑不同档位,同一种动物一次只跑一档)。
/// 变化即重建推理 worker。未下载的模型不可激活(下载完成按目标档自动激活)。
Future<void> setActive(int datasetId, String variant, bool active) async {
final key = (datasetId: datasetId, variant: variant);
final changed = active ? _active.add(key) : _active.remove(key);
if (!changed) return;
if (variant == _mode) {
_revision++;
_models = await _loadBundles(_catalog);
if (!active) {
if (!_active.remove(key)) return;
} else {
final keyActive = _active.contains(key);
final sameDsOthers = _active
.where((k) => k.datasetId == datasetId && k.variant != variant)
.toList();
if (keyActive && sameDsOthers.isEmpty) return; // 状态未变化
_active.removeAll(sameDsOthers);
_active.add(key);
}
await _saveActive();
_revision++;
_models = await _loadBundles(_catalog);
notifyListeners();
}
/// 自动更新:已下载/已激活的模型目录出现新版本时自动重下(保持原激活状态
/// 未下载的模型不自动拉取,避免无谓流量)。下载进度经 downloadModel 通知
/// 自动更新:已下载的模型目录出现新版本时自动重下(不改变激活状态——
/// 激活只在用户显式下载/启用时发生,2026-09-03 会话制修订)
/// 使用中的模型原地更新则立即生效(重建推理 worker)。未下载的模型不自动拉取,
/// 避免无谓流量。下载进度经 downloadModel 通知。
/// 2026-09-01 用户需求:发布新模型后 App 端自动更新,无需手动触发。
Future<void> autoUpdate() async {
if (_catalog.isEmpty) return;
final tracked = {..._downloaded, ..._active};
for (final item in _catalog) {
final key = (datasetId: item.datasetId, variant: item.variant);
if (!tracked.contains(key)) continue;
final wasActive = _active.contains(key);
if (!_downloaded.contains(key)) continue;
try {
// 后台 fire-and-forget:本地检查/下载都可能撞上存储变动(如清理),
// 不得向外抛未处理异步异常
if (await _isLocal(item)) continue;
final ok = await downloadModel(item);
// 原本未激活:下载完成自动激活后恢复原状态
if (ok && !wasActive) {
await setActive(item.datasetId, item.variant, false);
}
await downloadModel(item, autoActivate: false);
} catch (e) {
debugPrint('[ModelManager] 自动更新失败: ${item.datasetName} $e');
}
}
}
Future<void> _saveActive() async {
try {
final root = await _rootDir();
await root.create(recursive: true);
await File('${root.path}/active.json').writeAsString(jsonEncode({
'active': [
for (final k in _active) {'d': k.datasetId, 'v': k.variant}
]
}));
} catch (e) {
debugPrint('[ModelManager] 激活集持久化失败: $e');
}
}
/// 读取激活集。兼容旧版纯 int 列表(双档位前只有 s 档,int 一律归为 s)。
Future<void> _loadActive() async {
if (_activeLoaded) return;
_activeLoaded = true;
try {
final root = await _rootDir();
final f = File('${root.path}/active.json');
if (!await f.exists()) return;
final data = jsonDecode(await f.readAsString()) as Map<String, dynamic>;
for (final e in data['active'] as List? ?? const []) {
if (e is num) {
_active.add((datasetId: e.toInt(), variant: kVariantS));
} else if (e is Map) {
final v = e['v'] as String? ?? kVariantS;
final d = (e['d'] as num?)?.toInt();
if (d != null && v != kVariantS && v != kVariantN) continue;
if (d != null) _active.add((datasetId: d, variant: v));
}
}
} catch (e) {
debugPrint('[ModelManager] 激活集读取失败: $e');
}
}
Future<void> _saveMode() async {
try {
final root = await _rootDir();
@@ -527,11 +540,42 @@ class ModelManager extends ChangeNotifier {
}
}
/// 载入上次成功拉取的目录缓存(catalog.json,模型根目录下)——离线/弱网时
/// 设置弹层也能先展示模型清单。无缓存文件/损坏/空列表则保持目录为空。
Future<void> _loadCatalogCache() async {
try {
final root = await _rootDir();
final f = File('${root.path}/catalog.json');
if (!await f.exists()) return;
final data = jsonDecode(await f.readAsString()) as Map<String, dynamic>;
final list = data['models'] as List? ?? const [];
if (list.isEmpty) return;
_catalog = list
.map((e) => ModelCatalogItem.fromJson(e as Map<String, dynamic>))
.toList();
} catch (e) {
debugPrint('[ModelManager] 目录缓存读取失败: $e');
}
}
/// 落盘最近一次成功拉取的 models 原始列表(含服务器可能新增的字段),
/// 供下次离线/网络慢时先展示;缓存仅作展示降级,不参与清理/自动更新决策。
Future<void> _saveCatalogCache(List<dynamic> rawModels) async {
try {
final root = await _rootDir();
await root.create(recursive: true);
await File('${root.path}/catalog.json')
.writeAsString(jsonEncode({'models': rawModels}));
} catch (e) {
debugPrint('[ModelManager] 目录缓存保存失败: $e');
}
}
/// 读取全部激活条目(不再按目标档过滤——激活集即实际运行集,每数据集一档)
Future<List<ModelBundle>> _loadBundles(
List<ModelCatalogItem> catalog) async {
final bundles = <ModelBundle>[];
for (final item in catalog) {
if (item.variant != _mode) continue;
final key = (datasetId: item.datasetId, variant: item.variant);
if (!_active.contains(key)) continue;
try {
@@ -576,13 +620,24 @@ class ModelManager extends ChangeNotifier {
return Directory('${support.path}/models');
}
/// 档位目录:s 档 `models/<datasetId>/`legacy 无子目录,目录键 = 档位
/// 标识符的 s 形态,存量设备零迁移);n 档 `models/<datasetId>/n/`。
/// 档位子路径(相对模型根目录s 档 `models/<datasetId>/`legacy 无子目录,
/// 目录键 = 档位标识符的 s 形态,存量设备零迁移);n 档 `models/<datasetId>/n/`。
String _subPath(int datasetId, String variant) =>
variant == kVariantS ? '$datasetId' : '$datasetId/$variant';
/// 档位目录(不存在则创建)
Future<Directory> _modelDir(int datasetId, String variant) async {
final root = await _rootDir();
final sub = variant == kVariantS ? '' : '/$variant';
final dir = Directory('${root.path}/$datasetId$sub');
final dir = Directory('${root.path}/${_subPath(datasetId, variant)}');
await dir.create(recursive: true);
return dir;
}
/// 目录条目对应的模型文件是否已存在本地(不校验版本:旧版本视为已下载,
/// 新版本由 autoUpdate 自动补齐)
Future<bool> _hasFile(ModelCatalogItem item) async {
final root = await _rootDir();
return File('${root.path}/${_subPath(item.datasetId, item.variant)}/model.tflite')
.exists();
}
}
+7 -4
View File
@@ -6,7 +6,9 @@ import 'package:vibration/vibration.dart';
/// 提醒:同类目标 10s 内只提醒一次。
/// 震动/提示音默认开启,不提供关闭入口。
class Reminder {
final AudioPlayer _player = AudioPlayer();
// 惰性创建:AudioPlayer 构造即发起平台初始化(无插件环境下未处理错误会
// 泄漏为 unhandled async error),首响时才建
AudioPlayer? _player;
String? _lastAlertLabel;
int _lastAlertAt = 0;
@@ -24,11 +26,12 @@ class Reminder {
void _vibrate() => Vibration.vibrate(duration: 200);
Future<void> _playTone() async {
await _player.stop();
await _player.play(AssetSource('beep.wav'));
final p = _player ??= AudioPlayer();
await p.stop();
await p.play(AssetSource('beep.wav'));
}
void release() {
_player.dispose();
_player?.dispose();
}
}
-49
View File
@@ -1,49 +0,0 @@
import 'package:flutter/services.dart';
/// 安装进度事件(原生 PackageInstaller 会话回调经 EventChannel 回传)
class InstallEvent {
/// progress / finished / failed
final String event;
/// event=progress 时的安装进度 0-100
final int progress;
/// event=finished 时是否安装成功
final bool? success;
/// event=failed 时的错误描述
final String? error;
const InstallEvent({
required this.event,
this.progress = 0,
this.success,
this.error,
});
}
/// App 内安装 APK:原生侧 PackageInstaller 会话安装(InstallerChannel.kt
class ApkInstaller {
static const _method = MethodChannel('observer/installer');
static const _progress = EventChannel('observer/installer/progress');
/// 提交安装。返回 installing(已进入安装流程)/ permission_required
/// (未允许「安装未知应用」,原生侧已拉起系统设置页)。
static Future<String> install(String path) =>
_method.invokeMethod<String>('install', {'path': path}).then(
(v) => v ?? 'installing',
);
/// 安装进度流:progress(0-100) → finished(success) / failed(error)
static Stream<InstallEvent> progress() {
return _progress.receiveBroadcastStream().map((e) {
final m = e as Map;
return InstallEvent(
event: m['event'] as String? ?? '',
progress: (m['progress'] as num?)?.toInt() ?? 0,
success: m['success'] as bool?,
error: m['error'] as String?,
);
});
}
}
+5 -6
View File
@@ -17,7 +17,7 @@ class AppUpdateInfo {
}
/// 启动时版本更新检查:仅 Android 检查;服务器版本高于本地版本即强制更新
/// (无普通/强制之分)。
/// (无普通/强制之分)。2026-09-03 起更新走浏览器下载 APK 手动安装。
class UpdateChecker {
final String baseUrl;
final http.Client _client;
@@ -52,12 +52,11 @@ class UpdateChecker {
}
}
/// 是否需要更新:服务器版本高于已装版本与已确认接受版本」中的较大者。
/// APK 版本号不递增时,用户点过「立即更新」后 accepted 追上服务器版本,
/// 已更新完成再次启动也不会反复提示。
static bool needsUpdate(String server, String installed, String accepted) {
/// 是否需要更新:服务器版本高于已装版本2026-09-03 起更新改浏览器下载,
/// 安装结果由系统安装器完成,App 无法感知,故不再记录「已确认接受版本」)
static bool needsUpdate(String server, String installed) {
if (server.isEmpty || installed.isEmpty) return false;
return isNewer(server, installed) || isNewer(server, accepted);
return isNewer(server, installed);
}
/// 语义化版本号比较:a > b 返回 true。按数字段比较(1.10.0 > 1.9.9),
+27 -201
View File
@@ -1,219 +1,53 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:url_launcher/url_launcher.dart';
import 'installer.dart';
/// 强制更新页:检测到新版本时的全屏阻塞页。
/// PopScope 禁返回(Android 系统返回 / iOS 边缘滑动均不可退出)。
/// 进页自动在 App 内流式下载 APK(显示下载进度)→ PackageInstaller
/// 会话安装(显示安装进度),失败可手动重试,不再跳浏览器
/// 安装成功时回调 onUpdateAccepted(调用方持久化服务器版本号,
/// 使 APK 版本号不递增时也不反复提示)。
/// 2026-09-03 改为浏览器下载:App 内 PackageInstaller 会话安装在小米等
/// ROM 上点系统确认框后失败(下载/写入正常但最终安装被拒),而系统浏览器
/// 下载 APK 后经通知栏走 ROM 自己的安装器可正常完成,故下载链接改由浏览器
/// 打开,不再 App 内下载/安装。仅点「立即更新」按钮时打开浏览器,不自动
/// 跳转;安装完成(versionName 追上服务器)后下次启动不再提示。
class UpdateScreen extends StatefulWidget {
final String version;
final String url;
final String notes;
final VoidCallback? onUpdateAccepted;
const UpdateScreen({
super.key,
required this.version,
required this.url,
this.notes = '',
this.onUpdateAccepted,
});
@override
State<UpdateScreen> createState() => _UpdateScreenState();
}
enum _Stage { idle, downloading, installing, finished, failed }
class _UpdateScreenState extends State<UpdateScreen> {
final http.Client _client = http.Client();
final File _apkFile = File('${Directory.systemTemp.path}/observer-latest.apk');
final File _apkPart =
File('${Directory.systemTemp.path}/observer-latest.apk.part');
_Stage _stage = _Stage.idle;
/// 进度百分比 0-100;null = 总量未知(不确定进度条)
double? _progress;
String? _message;
StreamSubscription<InstallEvent>? _installSub;
/// 安装超时兜底:确认框未处理/系统无回调时避免永久卡「安装中」
Timer? _installTimer;
@override
void initState() {
super.initState();
// 自动更新:进页即自动下载并安装,无需手动点击(2026-09-01 用户需求)
WidgetsBinding.instance.addPostFrameCallback((_) => _launch());
}
@override
void dispose() {
_installTimer?.cancel();
_installSub?.cancel();
_client.close();
super.dispose();
}
Future<void> _launch() async {
if (_stage == _Stage.downloading ||
_stage == _Stage.installing ||
_stage == _Stage.finished) {
Future<void> _openBrowser() async {
final uri = Uri.tryParse(widget.url);
if (uri == null) {
setState(() => _message = '下载链接无效,请联系管理员');
return;
}
setState(() {
_stage = _Stage.idle;
_message = null;
});
if (!Platform.isAndroid) {
// 更新检查本就仅 Android 触发,这里兜底非 Android 走浏览器
final uri = Uri.tryParse(widget.url);
if (uri == null) return;
try {
await launchUrl(uri, mode: LaunchMode.externalApplication);
} catch (_) {}
return;
}
// APK 已下载完成(下载是原子落盘,.part 改名后文件才存在)→ 跳过下载直接安装
if (_apkFile.existsSync()) {
await _install();
return;
}
await _download();
}
Future<void> _download() async {
setState(() {
_stage = _Stage.downloading;
_progress = 0;
});
try {
if (_apkPart.existsSync()) _apkPart.deleteSync();
// 下载无总时长上限(APK 几十 MB 慢网可能数分钟);连接/响应头与
// 数据流分别做 30s 停滞判定,避免断流黑洞永久卡死
final res = await _client
.send(http.Request('GET', Uri.parse(widget.url)))
.timeout(const Duration(seconds: 30));
if (res.statusCode != 200) {
throw HttpException('HTTP ${res.statusCode}');
}
final total = res.contentLength ?? -1;
final sink = _apkPart.openWrite();
var received = 0;
await for (final chunk
in res.stream.timeout(const Duration(seconds: 30))) {
sink.add(chunk);
received += chunk.length;
if (mounted && total > 0) {
setState(() => _progress = received / total * 100);
}
}
await sink.close();
// 原子落盘:下载完成后才重命名为正式文件,避免残留半包被当成完整 APK
_apkPart.renameSync(_apkFile.path);
final ok = await launchUrl(uri, mode: LaunchMode.externalApplication);
if (!mounted) return;
await _install();
setState(() {
_message = ok ? '已打开浏览器下载,完成后点击通知栏的安装提示即可更新' : '打开浏览器失败,请点「立即更新」重试';
});
} catch (_) {
if (_apkPart.existsSync()) _apkPart.deleteSync();
if (!mounted) return;
setState(() {
_stage = _Stage.failed;
_progress = null;
_message = '下载失败,请检查网络后重试';
});
}
}
Future<void> _install() async {
setState(() {
_stage = _Stage.installing;
_progress = 0;
_message = null;
});
await _installSub?.cancel();
// 安装超时兜底:确认框未处理/系统无回调时避免永久卡「安装中」
_installTimer?.cancel();
_installTimer = Timer(const Duration(seconds: 120), () {
if (!mounted) return;
setState(() {
_stage = _Stage.failed;
_message = '安装超时,请重试';
});
});
_installSub = ApkInstaller.progress().listen((e) {
if (!mounted) return;
switch (e.event) {
case 'progress':
setState(() => _progress = e.progress.toDouble());
break;
case 'finished':
_installTimer?.cancel();
final ok = e.success == true;
// 安装成功才记录已接受版本:失败/取消时下次启动仍提示重试
if (ok) widget.onUpdateAccepted?.call();
setState(() {
_stage = ok ? _Stage.finished : _Stage.failed;
_message = ok ? '安装完成,请从桌面打开新版应用' : '安装失败,请重试';
});
break;
case 'failed':
_installTimer?.cancel();
debugPrint('UpdateScreen: install failed: ${e.error}');
setState(() {
_stage = _Stage.failed;
_message = e.error ?? '安装失败,请重试';
});
break;
}
}, onError: (Object _) {
_installTimer?.cancel();
if (!mounted) return;
setState(() {
_stage = _Stage.failed;
_message = '安装失败,请重试';
});
});
final String result;
try {
result = await ApkInstaller.install(_apkFile.path);
} catch (_) {
// 原生安装通道异常(如会话创建失败):不捕获则 UI 永久停在「安装中」
_installTimer?.cancel();
if (!mounted) return;
setState(() {
_stage = _Stage.failed;
_message = '安装启动失败,请重试';
});
return;
}
if (!mounted) return;
if (result == 'permission_required') {
// 原生侧已拉起系统设置页;APK 已缓存,用户开启后返回再点直达安装
_installTimer?.cancel();
setState(() {
_stage = _Stage.failed;
_message = '请在系统设置中允许「安装未知应用」,返回后再次点击「立即更新」(APK 已缓存,无需重新下载)';
});
setState(() => _message = '打开浏览器失败,请点「立即更新」重试');
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final busy = _stage == _Stage.downloading || _stage == _Stage.installing;
final progressText = _stage == _Stage.downloading
? (_progress == null ? '下载中…' : '下载中 ${_progress!.round()}%')
: (_progress == null ? '安装中…' : '安装中 ${_progress!.round()}%');
return PopScope(
canPop: false,
child: Scaffold(
@@ -234,26 +68,22 @@ class _UpdateScreenState extends State<UpdateScreen> {
textAlign: TextAlign.center,
style: const TextStyle(height: 1.6)),
const SizedBox(height: 24),
if (busy) ...[
LinearProgressIndicator(
value: _progress == null ? null : _progress! / 100,
minHeight: 6,
),
const SizedBox(height: 12),
Text(progressText),
const SizedBox(height: 24),
],
FilledButton.icon(
onPressed: busy || _stage == _Stage.finished
? null
: _launch,
icon: const Icon(Icons.download),
label: Text(_stage == _Stage.finished ? '已完成' : '立即更新'),
onPressed: _openBrowser,
icon: const Icon(Icons.open_in_browser),
label: const Text('立即更新'),
style: FilledButton.styleFrom(
minimumSize: const Size(200, 48),
textStyle: const TextStyle(fontSize: 16),
),
),
const SizedBox(height: 16),
Text(
'新版本需在浏览器下载 APK 后手动安装\n安装完成后请重新打开应用',
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall
?.copyWith(color: theme.colorScheme.error),
),
if (_message != null) ...[
const SizedBox(height: 12),
Text(
@@ -261,16 +91,12 @@ class _UpdateScreenState extends State<UpdateScreen> {
textAlign: TextAlign.center,
style: TextStyle(
height: 1.5,
color: _stage == _Stage.failed
? theme.colorScheme.error
: Colors.green,
color: _message!.startsWith('已打开')
? Colors.green
: theme.colorScheme.error,
),
),
],
const SizedBox(height: 12),
Text('不更新将无法继续使用',
style: theme.textTheme.bodySmall
?.copyWith(color: theme.colorScheme.error)),
],
),
),
+1 -1
View File
@@ -2,7 +2,7 @@ name: observer
description: "视野 - 动物实时识别 (环颈雉鸡/生境), YOLOv8 + 充值付费"
publish_to: 'none'
version: 1.0.24+29
version: 1.0.35+40
environment:
sdk: ^3.12.2
@@ -0,0 +1,93 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:observer/camera/camera_view_model.dart';
import 'package:observer/detection/detection_result.dart';
import 'package:observer/reminder/reminder.dart';
DetectionResult box(
String label,
double score,
double l,
double t,
double r,
double b, {
int modelId = -1,
int classId = 0,
}) =>
DetectionResult(
label: label,
score: score,
left: l,
top: t,
right: r,
bottom: b,
modelId: modelId,
classId: classId,
);
/// 真实 Reminder 会触碰音频/震动插件通道,测试用记录桩(onDetected 同步记录)。
/// AudioPlayer 构造里的 _create 平台调用在测试环境抛 MissingPluginException
/// 已被插件内部 catch,不影响用例。
class _RecordingReminder extends Reminder {
final List<String> alerts = [];
@override
void onDetected(String label) => alerts.add(label);
}
void feed(CameraViewModel vm, List<DetectionResult> results) {
vm.onFramesAnalyzed(results, 90, 1280, 720, const [], const [],
lastProcessMs: 8);
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('dedupeVisibleOverlaps', () {
test('同类别同位置重复框:只保留最高分', () {
final visible = [
box('雉鸡', 0.6, 0.3, 0.3, 0.5, 0.5, modelId: 1),
box('斑鸠', 0.5, 0.3, 0.3, 0.5, 0.5, modelId: 2),
];
final out = dedupeVisibleOverlaps(visible);
expect(out.length, 1);
expect(out.single.label, '雉鸡');
expect(out.single.score, 0.6);
});
test('不同类别同位置(目标×疑似):不同语义各自保留', () {
final visible = [
box('雉鸡', 0.6, 0.3, 0.3, 0.5, 0.5, classId: 0),
box('生境', 0.8, 0.2, 0.2, 0.6, 0.6, classId: 1),
];
final out = dedupeVisibleOverlaps(visible);
expect(out.length, 2);
});
test('中心不在对方框内的偏移重叠:两个独立目标不误并', () {
// 覆盖 ~40% 但中心互不在对方框内(并肩两个目标)
final visible = [
box('雉鸡', 0.7, 0.10, 0.10, 0.40, 0.40),
box('雉鸡', 0.5, 0.28, 0.10, 0.58, 0.40),
];
expect(dedupeVisibleOverlaps(visible).length, 2);
});
});
test('跨标签同目标补挂:交替检出同一目标只一条轨迹/一次提醒', () async {
final reminder = _RecordingReminder();
final vm = CameraViewModel(reminder: reminder);
feed(vm, [box('雉鸡', 0.6, 0.2, 0.2, 0.5, 0.5, modelId: 1)]);
// 越过 500ms 显示窗(确认轨迹开始显示、提醒窗口开启)
await Future<void>.delayed(const Duration(milliseconds: 600));
// 模型 2 检出同一目标:细长小框贴大框边角(中心距 ~0.2 > 跨标签收紧半径
// 0.072、覆盖 ~0.31 也不到 0.45),但小框中心在大框内 → 补挂进雉鸡
// 轨迹而不是新建第二条
feed(vm, [box('斑鸠', 0.55, 0.38, 0.445, 0.62, 0.535, modelId: 2)]);
await Future<void>.delayed(const Duration(milliseconds: 10));
expect(reminder.alerts, ['雉鸡'],
reason: '补挂进原轨迹:提醒按轨迹原标签只触发一次;若新建第二条轨迹'
'(斑鸠未达 500ms 显示窗)本帧不会触发提醒');
expect(vm.state.results.length, 1, reason: '同位置只有一条轨迹可见');
expect(vm.state.results.single.label, '斑鸠', reason: '框内容跟随后到的检测');
});
}
+126 -53
View File
@@ -120,33 +120,33 @@ void main() {
await tester.tap(finder);
}
testWidgets('未下载:显示使用按钮,点击后下载完成自动变为使用', (tester) async {
testWidgets('未下载:显示下载按钮,点击后下载完成自动变为使用', (tester) async {
final m = manager([_item()]);
await tester.runAsync(() => m.refresh());
await pumpSection(tester, m);
expect(find.text('数据集A'), findsOneWidget);
expect(find.text('使用'), findsOneWidget);
expect(find.text('下载'), findsOneWidget);
await tapVisible(tester, find.text('使用'));
await tapVisible(tester, find.text('下载'));
await tester.pump(); // 下载启动,进度条出现
// 驱动下载 + 自动激活的 IO 链走完,直至 UI 呈现「使用」
await pumpUntilFound(tester, find.text('使用'));
// 驱动下载 + 自动激活的 IO 链走完,直至 UI 呈现「使用
await pumpUntilFound(tester, find.text('使用'));
await tester.pumpAndSettle();
expect(m.isDownloaded(7, kVariantS), isTrue);
expect(m.isActive(7, kVariantS), isTrue);
expect(find.text('使用'), findsOneWidget);
expect(find.text('使用'), findsOneWidget);
});
testWidgets('已激活:再次点击取消使用', (tester) async {
testWidgets('已激活:点击使用中取消使用', (tester) async {
final m = manager([_item()]);
await tester.runAsync(() => m.refresh());
await tester.runAsync(() => m.downloadModel(m.catalog.first));
await pumpSection(tester, m);
expect(find.text('使用'), findsOneWidget);
await tapVisible(tester, find.text('使用'));
expect(find.text('使用'), findsOneWidget);
await tapVisible(tester, find.text('使用'));
// 排空 setActive 的激活集重载与落盘 IO,直至 UI 呈现「使用」
await pumpUntilFound(tester, find.text('使用'));
await tester.pumpAndSettle();
@@ -169,7 +169,7 @@ void main() {
expect(find.text('暂无已发布模型'), findsOneWidget);
});
testWidgets('下载中:显示进度和取消按钮,取消后恢复使用', (tester) async {
testWidgets('下载中:显示进度和取消按钮,取消后恢复下载', (tester) async {
final m = ModelManager(
baseUrl: 'http://test.local',
client: _StreamingClient(),
@@ -178,83 +178,156 @@ void main() {
await tester.runAsync(() => m.refresh());
await pumpSection(tester, m);
await tapVisible(tester, find.text('使用'));
await tapVisible(tester, find.text('下载'));
// 首个分块到达,进度条与取消按钮出现(IO 链需交替驱动)
await pumpUntilFound(tester, find.text('取消'),
fake: const Duration(milliseconds: 400));
expect(find.text('取消'), findsOneWidget);
await tapVisible(tester, find.text('取消'));
// 推进 fake 时钟触发延迟分块 → 取消分支清场(.part 删除等真实 IO),直至恢复「使用
await pumpUntilFound(tester, find.text('使用'),
// 推进 fake 时钟触发延迟分块 → 取消分支清场(.part 删除等真实 IO),直至恢复「下载
await pumpUntilFound(tester, find.text('下载'),
fake: const Duration(milliseconds: 400));
await tester.pumpAndSettle();
expect(m.isDownloaded(7, kVariantS), isFalse);
expect(m.isActive(7, kVariantS), isFalse);
expect(find.text('使用'), findsOneWidget);
expect(find.text('下载'), findsOneWidget);
});
testWidgets('双档位:s/n 两卡独立,n 档下载备好后切档即用', (tester) async {
final m =
manager([_item(), _item(variant: kVariantN, name: '数据集B')]);
testWidgets('双档位:同物种合并一张卡,下载只启用目标档,使用即停旧档', (tester) async {
final m = manager([_item(), _item(variant: kVariantN)]);
await tester.runAsync(() => m.refresh());
await pumpSection(tester, m);
// 分段控件默认 s;两卡并排,s 卡按钮「使用」,非当前档 n 卡按钮「下载」
expect(find.text('s 高识别'), findsOneWidget);
// 合并卡:同一数据集只一张卡,无双卡/无档位角标;双档行标注两档
expect(find.text('数据集A'), findsOneWidget);
expect(find.text('数据集B'), findsOneWidget);
expect(find.text('s'), findsOneWidget, reason: '档位角标');
expect(find.text('n'), findsOneWidget, reason: '档位角标');
expect(find.text('使用'), findsOneWidget);
expect(find.text('下载'), findsOneWidget, reason: '非当前档入口为「下载」');
expect(find.text('下载'), findsOneWidget, reason: '合并卡只有一个主按钮');
expect(find.textContaining('高精度 v1.0.0'), findsOneWidget);
expect(find.textContaining('高性能 v1.0.0'), findsOneWidget);
// 先激活 s 档
await tapVisible(tester, find.text('使用'));
await pumpUntilFound(tester, find.text('已使用'));
expect(m.isActive(7, kVariantS), isTrue);
// 下载 n 档:完成后自动备好(n 非当前档),显示「已备好」而非「已使用」
// 一次下载取回双档:同物种只启用目标档(默认高精度 s),n 档备好不启用。
// 双档下载收尾链交错,等 models 真正重载完成再断言
await tapVisible(tester, find.text('下载'));
await pumpUntilFound(tester, find.text('已备好'));
await tester.pumpAndSettle();
expect(m.isActive(7, kVariantN), isTrue);
expect(m.mode, kVariantS, reason: '下载非当前档不应改变识别档位');
expect(m.models.length, 1);
expect(m.models.first.variant, kVariantS, reason: '当前仍只加载 s 档');
// 点「已备好」切换档位 → n 档立即生效(已使用),s 档转为备好态
await tapVisible(tester, find.text('已备好'));
// 「已使用」切换前后都在场(s 使用 → n 使用),须等 manager 状态真正切档
await pumpUntilFound(tester, find.text('使用中'));
for (var i = 0;
i < 50 &&
!(m.mode == kVariantN &&
m.models.isNotEmpty &&
m.models.first.variant == kVariantN);
!(m.isDownloaded(7, kVariantS) &&
m.isDownloaded(7, kVariantN) &&
m.isActive(7, kVariantS) &&
!m.isActive(7, kVariantN) &&
m.models.length == 1 &&
m.models.first.variant == kVariantS);
i++) {
await pumpRealIo(tester);
}
await tester.pumpAndSettle();
expect(m.mode, kVariantN);
expect(m.isDownloaded(7, kVariantS), isTrue);
expect(m.isDownloaded(7, kVariantN), isTrue);
expect(m.isActive(7, kVariantS), isTrue);
expect(m.isActive(7, kVariantN), isFalse,
reason: '同物种同时只运行目标档,另一档备好不叠加启用');
expect(m.mode, kVariantS, reason: '下载双档不应改变目标档');
expect(m.models.length, 1);
expect(m.models.first.variant, kVariantN, reason: '切档后热加载 n 档模型');
expect(find.text('使用'), findsOneWidget, reason: 'n 档卡已使用');
expect(find.text('已备好'), findsOneWidget, reason: 's 档转为备好态');
expect(m.models.first.variant, kVariantS);
expect(find.text('使用'), findsOneWidget);
// 目标档切到高性能:运行中的 s 不受影响;n 已下载未启用 → 主按钮「使用」
// (保存目标档偏好是真实 IO,等按钮呈现才算 UI 落定;2026-09-03 简化:
// 不再叫「改用X」、不显示「当前使用 X」提示)
await tapVisible(tester, find.text('高性能'));
await pumpUntilFound(tester, find.text('使用'));
await tester.pumpAndSettle();
expect(m.mode, kVariantN);
expect(m.isActive(7, kVariantS), isTrue,
reason: '切换目标档不应改变运行中的模型');
expect(find.text('使用'), findsOneWidget);
expect(find.textContaining('当前使用'), findsNothing);
// 点「使用」:同物种 s 自动停用,n 热启用(setActive 尾部的落盘
// 与 notify 是真实 IO,等 UI 呈现「使用中」才算整链落定)
await tapVisible(tester, find.text('使用'));
await pumpUntilFound(tester, find.text('使用中'));
await tester.pumpAndSettle();
expect(m.isActive(7, kVariantS), isFalse, reason: '启用目标档自动停用另一档');
expect(m.isActive(7, kVariantN), isTrue);
expect(m.models.length, 1);
expect(m.models.first.variant, kVariantN);
expect(find.text('使用中'), findsOneWidget, reason: '主按钮跟随当前档');
});
testWidgets('识别模式分段控件:点击切档生效', (tester) async {
final m = manager([_item(), _item(variant: kVariantN, name: '数据集A')]);
testWidgets('不同动物可用不同档位:使用只作用于点击的动物', (tester) async {
final m = manager([
_item(datasetId: 7, name: '数据集A'),
_item(datasetId: 7, variant: kVariantN, name: '数据集A'),
_item(datasetId: 8, name: '数据集B'),
_item(datasetId: 8, variant: kVariantN, name: '数据集B'),
]);
await tester.runAsync(() => m.refresh());
// 两个物种都下载并运行默认档(高精度 s),各自 n 档备好
for (final it in m.catalog) {
await tester.runAsync(() => m.downloadModel(it));
}
await pumpSection(tester, m);
await tapVisible(tester, find.text('n 高性能'));
for (var i = 0; i < 50 && m.mode != kVariantN; i++) {
expect(m.models.length, 2);
expect(find.text('使用中'), findsNWidgets(2));
// 目标档切高性能:两卡主按钮都变「使用」(各自 s 仍在运行、n 已备好)
await tapVisible(tester, find.text('高性能'));
await pumpUntilFound(tester, find.text('使用'));
expect(find.text('使用'), findsNWidgets(2));
// 只点第一张卡(数据集 7,目录序在前):该动物切到高性能,另一动物保持高精度
await tapVisible(tester, find.text('使用').first);
await pumpUntilFound(tester, find.text('使用中')); // 数据集 7 的卡先翻为使用中
for (var i = 0;
i < 50 &&
!(m.models.length == 2 &&
m.models.firstWhere((b) => b.datasetId == 7).variant ==
kVariantN &&
m.models.firstWhere((b) => b.datasetId == 8).variant ==
kVariantS);
i++) {
await pumpRealIo(tester);
}
await tester.pumpAndSettle();
expect(m.mode, kVariantN, reason: '点击分段控件应切档');
// 档位持久化(重启恢复)由 model_manager_test「识别档位持久化」覆盖;
expect(m.isActive(7, kVariantN), isTrue);
expect(m.isActive(7, kVariantS), isFalse, reason: '启用目标档自动停用同动物另一档');
expect(m.isActive(8, kVariantS), isTrue, reason: '未点击的动物保持原档位');
expect(m.isActive(8, kVariantN), isFalse);
expect(m.models.length, 2, reason: '两个动物仍同时识别');
expect(m.models.firstWhere((b) => b.datasetId == 7).variant, kVariantN);
expect(m.models.firstWhere((b) => b.datasetId == 8).variant, kVariantS);
expect(find.text('使用中'), findsOneWidget, reason: '只有数据集 7 的卡使用中');
expect(find.text('使用'), findsOneWidget,
reason: '数据集 8 目标档已备好:按钮就是使用(不叫改用)');
expect(find.textContaining('当前使用'), findsNothing);
});
testWidgets('识别模式分段控件:只改目标档偏好,不影响已在运行的模型', (tester) async {
final m = manager([_item(), _item(variant: kVariantN)]);
await tester.runAsync(() => m.refresh());
await tester.runAsync(() => m.downloadModel(m.catalog.first)); // s 启用
await pumpSection(tester, m);
expect(find.text('高精度'), findsOneWidget, reason: '分段控件选中默认档');
expect(find.text('高性能'), findsOneWidget);
expect(find.text('使用中'), findsOneWidget);
await tapVisible(tester, find.text('高性能'));
// 目标档 n 尚未下载(之前只下了 s)→ 主按钮变为「下载」:仍不切运行模型
await pumpUntilFound(tester, find.text('下载'));
await tester.pumpAndSettle();
expect(m.mode, kVariantN, reason: '点击分段控件应改目标档');
expect(m.isActive(7, kVariantS), isTrue, reason: '只改偏好不切运行模型');
expect(m.models.length, 1);
expect(m.models.first.variant, kVariantS, reason: '没有热加载/切换');
expect(find.text('下载'), findsOneWidget, reason: '卡片按钮面向新目标档');
expect(find.textContaining('当前使用'), findsNothing);
// 目标档偏好持久化由 model_manager_test「识别目标档位偏好持久化」覆盖;
// widget 测试内 tap 触发的文件写与真实 IO 交错不可控,不在本层断言重启
});
}
+180 -31
View File
@@ -153,23 +153,28 @@ void main() {
expect(m2.models.first.version, 'v2.0.0');
});
test('autoUpdate:已激活模型出新版本,refresh 后自动重下并立即生效', () async {
test('autoUpdate:已下载模型出新版本,自动补齐文件但不改变激活状态', () async {
final m = manager(client([_item()]));
await m.refresh();
await m.downloadModel(m.catalog.first);
await m.downloadModel(m.catalog.first); // (7, s) v1 下载并激活
expect(downloadHits, 1);
// 重启 + 服务器目录出 v2refresh 内部 autoUpdate 自动重下(无需手动)
// 模拟新会话(新实例 + 服务器目录出 v2):文件仍在 → autoUpdate 只补文件
final m2 = manager(client([_item(version: 'v2.0.0')]));
await m2.refresh();
for (var i = 0; i < 50 && downloadHits < 2; i++) {
await Future<void>.delayed(const Duration(milliseconds: 20));
}
expect(downloadHits, 2, reason: '新版本应自动重下');
expect(m2.isActive(7, kVariantS), isTrue, reason: '自动更新应保持激活');
expect(m2.models.first.version, 'v2.0.0',
reason: '自动更新后立即生效新版本字节');
expect(downloadHits, 2, reason: '新版本应自动补齐(只更新文件不激活)');
expect(m2.isDownloaded(7, kVariantS), isTrue);
expect(m2.isActive(7, kVariantS), isFalse,
reason: '会话制:自动补齐不激活任何模型');
expect(m2.models, isEmpty, reason: '新会话从仅预览开始');
// 手动启用后跑的是新版本
await m2.setActive(7, kVariantS, true);
expect(m2.models.first.version, 'v2.0.0');
expect(m2.models.first.bytes, _modelBytes);
});
@@ -189,17 +194,25 @@ void main() {
expect(m2.errorOf(7, kVariantS), isNotNull);
});
test('激活集持久化:重启后恢复激活且已下载的模型', () async {
test('会话制:进入新会话清空激活,文件保留,再启用不重新下载', () async {
final m = manager(client([_item()]));
await m.refresh();
await m.downloadModel(m.catalog.first);
expect(m.isActive(7, kVariantS), isTrue);
// 同一 root 新建 manager 模拟重启
final m2 = manager(client([_item()]));
await m2.refresh();
expect(m2.isActive(7, kVariantS), isTrue, reason: '激活集应持久化');
expect(m2.models.length, 1);
expect(downloadHits, 1, reason: '重启不应触发下载');
// 每次进入视野页 = 新会话:清空激活与已加载模型(不恢复上次使用的模型)
m.resetForSession();
expect(m.isActive(7, kVariantS), isFalse,
reason: '新会话不自动恢复上次使用的模型');
expect(m.models, isEmpty);
expect(m.isDownloaded(7, kVariantS), isTrue, reason: '文件保留:清单显示已下载');
// 手动启用:直接用本地文件,不重新下载
final before = downloadHits;
await m.setActive(7, kVariantS, true);
expect(m.isActive(7, kVariantS), isTrue);
expect(m.models.length, 1);
expect(downloadHits, before, reason: '已下载启用不应重新下载');
});
test('目录下线:清理本地并移除激活', () async {
@@ -272,24 +285,24 @@ void main() {
reason: '取消后 .part 残留应被清理');
});
test('双档位:n 档独立目录 models/7/n/非当前档下载不加载', () async {
test('双档位:n 档独立目录 models/7/n/同物种只运行目标档', () async {
final m = manager(client(
[_item(), _item(variant: kVariantN, name: '数据集A', version: 'v1.0.0')]));
await m.refresh();
expect(m.catalog.length, 2);
// 默认 s:下载 s 自动激活并加载
// 默认目标档 s:下载 s 自动激活并加载
await m.downloadModel(m.catalog.first); // (7, s)
expect(m.models.length, 1);
expect(m.models.first.variant, kVariantS);
// 非当前档(n)下载:自动备好(激活保留),但 s 档加载列表不变
// 同物种已有 s 在使用时下载 n:备好不启用(同物种一次只运行一档)
final nItem = m.catalog.last;
expect(nItem.variant, kVariantN);
await m.downloadModel(nItem);
expect(m.isDownloaded(7, kVariantN), isTrue);
expect(m.isActive(7, kVariantN), isTrue, reason: '下载完成应自动备好');
expect(m.models.length, 1, reason: '非当前档模型不应进入加载列表');
expect(m.isActive(7, kVariantN), isFalse, reason: '同物种运行中不叠加启用');
expect(m.models.length, 1, reason: '备好的 n 档不进加载列表');
expect(m.models.first.variant, kVariantS);
// n 档文件在独立子目录(目录键 = 档位),s 档仍为同级文件
@@ -298,38 +311,108 @@ void main() {
expect(await File('${root.path}/7/model.tflite').exists(), isTrue,
reason: 's 档 legacy 同级布局保持不变');
// 切档:热加载 n 档已备模型,s 档不再加载(无需重新下载)
// 切目标档:只改偏好,不切换运行中的模型
final before = downloadHits;
await m.setMode(kVariantN);
expect(m.mode, kVariantN);
expect(downloadHits, before, reason: '切档不应触发下载');
expect(downloadHits, before, reason: '目标档不应触发下载');
expect(m.isActive(7, kVariantS), isTrue, reason: '切档不改运行状态');
expect(m.models.length, 1);
expect(m.models.first.variant, kVariantS);
// 改用 n:同物种 s 自动停用,n 启用(无需重新下载)
await m.setActive(7, kVariantN, true);
expect(m.isActive(7, kVariantS), isFalse, reason: '改用自动停用另一档');
expect(m.isActive(7, kVariantN), isTrue);
expect(m.models.length, 1);
expect(m.models.first.variant, kVariantN);
expect(m.models.first.datasetName, '数据集A');
// 切回 s 档恢复 s 模型
await m.setMode(kVariantS);
await m.setActive(7, kVariantS, true);
expect(m.models.length, 1);
expect(m.models.first.variant, kVariantS);
expect(m.isActive(7, kVariantN), isFalse);
});
test('识别档位持久化:重启后保持档位并加载该档已激活模型', () async {
test('识别目标档位偏好持久化;激活集为会话态不跨重启', () async {
final m = manager(client(
[_item(), _item(variant: kVariantN, name: '数据集A', version: 'v1.0.0')]));
await m.refresh();
await m.downloadModel(m.catalog.first); // (7, s)
await m.downloadModel(m.catalog.last); // (7, n) 备好
await m.downloadModel(m.catalog.first); // (7, s) 下载自动激活
await m.downloadModel(m.catalog.last); // (7, n) 备好不启用
await m.setMode(kVariantN);
// 同一 root 新建 manager 模拟重启:档位与激活集都持久化
// 同一 root 新建 manager 模拟重启:目标档偏好持久化,激活集不恢复
final m2 = manager(client(
[_item(), _item(variant: kVariantN, name: '数据集A', version: 'v1.0.0')]));
await m2.refresh();
expect(m2.mode, kVariantN, reason: '识别档位应持久化');
expect(m2.isActive(7, kVariantS), isTrue, reason: '激活集应持久化');
expect(m2.models.length, 1);
expect(m2.models.first.variant, kVariantN, reason: '重启后加载当前档位模型');
expect(downloadHits, 2, reason: '重启与切档不应触发下载');
expect(m2.mode, kVariantN, reason: '识别目标档位是用户偏好,应持久化');
expect(m2.isActive(7, kVariantS), isFalse, reason: '激活集不跨会话恢复');
expect(m2.isActive(7, kVariantN), isFalse);
expect(m2.models, isEmpty, reason: '重启/新会话从仅预览开始');
expect(m2.isDownloaded(7, kVariantS), isTrue, reason: '文件保留可即点即用');
expect(m2.isDownloaded(7, kVariantN), isTrue);
expect(downloadHits, 2, reason: '重启不应触发下载');
});
test('多物种混合档位:改用一物种不影响其它物种的档位', () async {
final m = manager(client([
_item(datasetId: 7, name: '数据集A'),
_item(datasetId: 7, variant: kVariantN, name: '数据集A'),
_item(datasetId: 8, name: '数据集B'),
_item(datasetId: 8, variant: kVariantN, name: '数据集B'),
]));
await m.refresh();
for (final it in m.catalog) {
await m.downloadModel(it);
}
expect(m.models.length, 2);
expect(m.isActive(7, kVariantS), isTrue);
expect(m.isActive(8, kVariantS), isTrue);
// 只把 7 改到 n:8 保持 s —— 两物种不同档位并行运行
await m.setActive(7, kVariantN, true);
expect(m.models.length, 2);
final byId = {for (final b in m.models) b.datasetId: b.variant};
expect(byId, {7: kVariantN, 8: kVariantS});
expect(m.isActive(7, kVariantS), isFalse);
expect(m.isActive(8, kVariantS), isTrue);
expect(m.isActive(8, kVariantN), isFalse);
// 7 改回 s
await m.setActive(7, kVariantS, true);
expect(m.models.length, 2);
expect(m.models.firstWhere((b) => b.datasetId == 7).variant, kVariantS);
});
test('目标档决定首次下载自动启用:非目标档先落地不启用,目标档落地启用', () async {
final m = manager(client(
[_item(), _item(variant: kVariantN, name: '数据集A', version: 'v1.0.0')]));
await m.refresh();
await m.setMode(kVariantN); // 目标高性能
// 先下 s(非目标档):数据集尚无档位在使用,但不应启用 s
await m.downloadModel(m.catalog.first);
expect(m.isDownloaded(7, kVariantS), isTrue);
expect(m.isActive(7, kVariantS), isFalse, reason: '非目标档落地不自动启用');
expect(m.models, isEmpty);
// 目标档 n 落地:自动启用(s 保持备好)
await m.downloadModel(m.catalog.last);
expect(m.isActive(7, kVariantN), isTrue);
expect(m.isActive(7, kVariantS), isFalse);
expect(m.models.length, 1);
expect(m.models.first.variant, kVariantN);
});
test('单档目录(无目标档条目):下载即自动启用仅有的档', () async {
final m = manager(client([_item()])); // 目录只有 s
await m.refresh();
await m.setMode(kVariantN); // 目标高性能但目录没有 n
await m.downloadModel(m.catalog.first);
expect(m.isActive(7, kVariantS), isTrue, reason: '无目标档时启用仅有的档');
expect(m.models.length, 1);
});
test('数据集仍在但 n 档下线:清理 n 档子目录,保留数据集目录', () async {
@@ -345,4 +428,70 @@ void main() {
expect(await Directory('${root.path}/7').exists(), isTrue,
reason: '数据集仍在(s 档):不应删整目录');
});
test('目录缓存:断网重启后展示上次目录、标出已下载、保留文件且不恢复激活', () async {
final m = manager(client([_item()]));
await m.refresh();
await m.downloadModel(m.catalog.first); // (7, s) 下载并激活
expect(m.isDownloaded(7, kVariantS), isTrue);
// 模拟重启 + 断网:同 root 新实例、目录接口失败
final offline =
manager(MockClient((_) async => http.Response('boom', 500)));
await offline.refresh();
expect(offline.catalog.length, 1, reason: '无网络也应以缓存目录展示模型清单');
expect(offline.catalog.first.datasetId, 7);
expect(offline.error, isNotNull, reason: '未拉取成功过仍提示目录刷新失败');
expect(offline.ready, isFalse);
expect(offline.isDownloaded(7, kVariantS), isTrue,
reason: '缓存目录同样扫描本地文件:按钮不误导为未下载');
expect(offline.isActive(7, kVariantS), isFalse, reason: '会话制:不恢复激活');
expect(offline.models, isEmpty);
expect(await Directory('${root.path}/7').exists(), isTrue,
reason: '离线降级(非权威目录)不得清理本地模型');
});
test('目录缓存:网络成功覆盖缓存,再离线读到最近一次目录', () async {
final m = manager(client([_item(datasetId: 7, name: '数据集A')]));
await m.refresh(); // 缓存 v1:只有数据集 7 的 s 档
final m2 = manager(client([
_item(datasetId: 7, name: '数据集A'),
_item(datasetId: 7, variant: kVariantN, name: '数据集A'),
_item(datasetId: 8, name: '数据集B'),
]));
await m2.refresh();
expect(m2.catalog.length, 3);
final offline =
manager(MockClient((_) async => http.Response('boom', 500)));
await offline.refresh();
expect(offline.catalog.length, 3, reason: '缓存应为最近一次成功拉取的目录');
expect(offline.catalog.map((c) => c.datasetId).toSet(), {7, 8});
expect(
offline.catalog
.where((c) => c.datasetId == 7)
.map((c) => c.variant)
.toSet(),
{kVariantS, kVariantN});
});
test('升级后断网首启(无缓存文件):拉取失败不执行任何清理', () async {
// 旧版本下载的文件直接落盘(无 catalog.json):升级首启即断网的最坏情况
final dir = Directory('${root.path}/9');
await dir.create(recursive: true);
await File('${dir.path}/model.tflite').writeAsBytes(_modelBytes);
await File('${dir.path}/meta.json')
.writeAsString(jsonEncode({'version': 'v1.0.0'}));
final offline =
manager(MockClient((_) async => http.Response('boom', 500)));
await offline.refresh();
expect(offline.error, isNotNull);
expect(offline.catalog, isEmpty, reason: '无缓存可兜底:无可展示目录');
expect(await dir.exists(), isTrue,
reason: '清理只在网络拉取成功后执行,离线首启不得误删已下载模型');
});
}
+45
View File
@@ -13,6 +13,20 @@ DetectionResult box(String label, double score, double x, double y,
bottom: y + 0.1,
modelId: modelId,
modelName: modelName,
classId: 0,
);
DetectionResult rect(String label, double score, double l, double t, double r,
double b, {int modelId = -1}) =>
DetectionResult(
label: label,
score: score,
left: l,
top: t,
right: r,
bottom: b,
modelId: modelId,
classId: 0,
);
void main() {
@@ -63,4 +77,35 @@ void main() {
final merged = mergeAcrossModels(single, 0.45);
expect(identical(merged, single), isTrue);
});
test('不同模型偏移框(小框中心在大框内):0.45 阈值外的窗口也去重', () {
// 覆盖 ≈ 0.31 < 0.45:纯阈值会漏;小框中心在大框内 → 同一目标取高分
// 2026-09-03:不同输入分辨率模型对同一目标的框几何系统性偏移)
final all = [
rect('target', 0.5, 0.38, 0.445, 0.62, 0.535, modelId: 1),
rect('second', 0.6, 0.2, 0.2, 0.5, 0.5, modelId: 2),
];
final merged = mergeAcrossModels(all, 0.45);
expect(merged.length, 1);
expect(merged.first.label, 'second');
expect(merged.first.score, 0.6);
});
test('同模型偏移框:不套用跨模型窗口,正常保留', () {
final all = [
rect('target', 0.5, 0.38, 0.445, 0.62, 0.535, modelId: 1),
rect('second', 0.6, 0.2, 0.2, 0.5, 0.5, modelId: 1),
];
final merged = mergeAcrossModels(all, 0.45);
expect(merged.length, 2, reason: '同模型框对由模型内类内 NMS 负责');
});
test('无来源模型(modelId -1):不套用跨模型窗口(存量语义不变)', () {
final all = [
rect('target', 0.5, 0.38, 0.445, 0.62, 0.535),
rect('second', 0.6, 0.2, 0.2, 0.5, 0.5),
];
final merged = mergeAcrossModels(all, 0.45);
expect(merged.length, 2);
});
}