This commit is contained in:
2026-09-13 00:25:08 +08:00
parent cc42045734
commit 5a97655646
13 changed files with 200 additions and 34 deletions
+38
View File
@@ -123,3 +123,41 @@ GPU delegate 默认允许 FP16 计算(YOLO 类精度损失可忽略);如
"Node number 0 (TRANSPOSE) failed to prepare"。
- **模拟器黑屏**:本机 iOS 模拟器 Impeller 渲染黑屏,验证 UI 用 VM service
`flutter run` 输出里的 DevTools 地址),或直接真机验证。
## 识别链路约束(踩过的坑)
### 归一化坐标不得与像素量纲阈值直接比较(2026-09-12 漏检根因)
**现象**:验证图 `RNPHE_2030`(麦田平卧雉鸡)离线推理 0.8845、与真值 IoU 0.88
App 端对准画面却一个框都不出;换张图/换个握持方向又偶尔能出——"有时能识别、
有时识别不出来"。
**根因**`camera_view_model.dart` 的 `_plausible()` 拿 `w / h` 与固定窗口
`0.3 ~ 3.0` 比较,但 `left/top/right/bottom` 是**按各自轴归一化**的(w 除以图宽、
h 除以图高),于是:
```
归一化宽高比 = 像素宽高比 × (图高 / 图宽)
```
竖屏画幅(图高/图宽 ≈ 1.78)下 `0.3 ~ 3.0` 实际只剩像素宽高比 `0.17 ~ 1.69`
雉鸡是长尾鸟(本例 199×89px,像素宽高比 2.24),归一化后 3.99 > 3.0 →
**在建轨迹之前被 `continue` 丢掉,置信度再高也不显示**。横屏时窗口是 0.53~5.33
所以能出框——这就是"有时能、有时不能"随握持方向/目标姿态变化的来源。
**修法(2026-09-12 定案)**:几何门**整体删除**(过近/过远目标同样不能丢),
只保留"零宽/零高"的退化数据兜底;质量交给置信度(minScore+ VisualPrior
(仅作用于 < 0.35 的框)+ 轨迹确认(3 帧 / ≥ 0.35 / 运动证据)把关。
**规矩(改识别链路时照做)**
1. 任何宽高比/尺寸判断必须先换算回**像素空间**再比阈值:
`aspectPx = (w * frameW) / (h * frameH)`。直接拿 `w/h` 比固定常量,在非方图上必然错。
2. 显示链路上会**静默丢弃真框**的过滤(`continue` 不留痕)必须满足其一:
只作用于低分框(同 VisualPrior 的分工)、或有可观测口径(诊断层/日志)——
否则"模型检出了但没显示"无从定位。
3. 回归用例已锁死:`test/camera_view_model_test.dart` 的「平卧长条框不被几何过滤」
与「近距离大目标与远距离小目标均可显示」——两条在旧逻辑下必然失败,动这块先跑它们。
**定位手法**:诊断层(状态胶囊 3 秒内连点 5 次)看「最高分」——
**高分无框 = 卡在过滤链路;最高分也低 = 卡在采集/推理链路**
+2
View File
@@ -57,6 +57,8 @@
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
<key>NSAllowsArbitraryLoadsInWebContent</key>
<true/>
</dict>
<key>NSCameraUsageDescription</key>
<string>需要使用相机进行动物实时识别</string>
+3 -3
View File
@@ -39,7 +39,7 @@ class _CameraScreenState extends State<CameraScreen> {
String? _initError;
/// 置信度阈值(设置页滑块调整,worker 内实时生效)
double _minScore = 0.10;
double _minScore = 0.015;
/// 原生侧帧状态轮询结果(诊断用;无帧时诊断行也能实时刷新)
Map<dynamic, dynamic> _nativeStats = const {};
@@ -181,9 +181,9 @@ class _CameraScreenState extends State<CameraScreen> {
),
Slider(
value: _minScore,
min: 0.05,
min: 0.01,
max: 0.50,
divisions: 45,
divisions: 49,
activeColor: Colors.greenAccent,
onChanged: (v) {
setSheetState(() => _minScore = v);
+4 -12
View File
@@ -173,7 +173,10 @@ class CameraViewModel extends ChangeNotifier {
int now) {
final matched = <int>{};
for (final r in results) {
if (!_plausible(r)) continue;
// 仅挡退化数据(零宽/零高):尺寸与宽高比不设限——过近/过远的真实
// 目标同样要显示(2026-09-12 用户定案:原几何门把远处小鸟和近处
// 大目标一并丢弃;质量交给置信度 + 轨迹确认把关)
if (r.width <= 0 || r.height <= 0) continue;
_Track? best;
var bestD = associateRadius;
for (final t in _tracks.values) {
@@ -241,17 +244,6 @@ class CameraViewModel extends ChangeNotifier {
motionRegions.any((m) => MotionAggregator.centerInRegion(r, m)) ||
noveltyRegions.any((m) => MotionAggregator.centerInRegion(r, m));
/// 物理合理性过滤:宽高比与相对尺寸(目标物种 20-100px@720 量级)
bool _plausible(DetectionResult r) {
final h = r.height;
final w = r.width;
if (w <= 0 || h <= 0) return false;
final aspect = w / h;
if (aspect < 0.3 || aspect > 3.0) return false;
if (r.label == 'suspect') return h >= 0.01 && h <= 0.5;
return h >= 0.01 && h <= 0.3;
}
double _centerDist(DetectionResult a, DetectionResult b) =>
math.sqrt(math.pow(a.centerX - b.centerX, 2) +
math.pow(a.centerY - b.centerY, 2));
+101
View File
@@ -0,0 +1,101 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:sensors_plus/sensors_plus.dart';
/// 陀螺仪全局运动追踪(主 isolate):对角速度按 dt 积分得到两次运动分析帧
/// 之间的累计转角,折算为缩略图像素位移,供 MotionDetector 做全局运动补偿——
/// 补偿后差分 = 目标独立运动(手持/走动的旋转分量被抵消)。
///
/// 符号约定(背部相机、竖屏、假设无镜像):绕设备竖轴(y)偏航 → 画面水平位移;
/// 绕设备横轴(x)俯仰 → 画面垂直位移。kPanSign/kTiltSign 待真机校准:
/// 若补偿后误报反而变多(差分被放大),把对应符号取反即可。
/// 位移超限(缩略图短边 1/4)时由消费方丢弃该帧——运动过快时像素级补偿不可靠。
class GyroTracker {
static const double assumedVfovDeg = 52; // 与测距口径一致
static const double kPanSign = 1;
static const double kTiltSign = 1;
StreamSubscription<GyroscopeEvent>? _gyroSub;
StreamSubscription<AccelerometerEvent>? _accSub;
double _panRad = 0; // 两次 takeShift 之间的累计偏航
double _tiltRad = 0; // 累计俯仰
DateTime? _lastGyroMs;
double _accLowZ = 9.8; // 加速度计低通(重力在设备 z 轴分量)
double _accLowY = 0; // 低通 y 轴分量(算俯仰用)
bool _hasAcc = false;
/// 当前分析窗口内的角速度峰值(|x|+|y|+|z|,rad/s):走动/车载时显著抬升。
/// takeShift 消费时清零(按分析窗口计量),供曝光联动等判定
double _recentOmega = 0;
bool _running = false;
bool get hasData => _hasGyro;
bool _hasGyro = false;
/// 角速度峰值超阈值(rad/s)→ 机位正在明显运动(手持快走/车载)
bool get recentlyMoving => _recentOmega > 0.5;
void start() {
if (_running) return;
_running = true;
_gyroSub = gyroscopeEventStream().listen((e) {
final now = DateTime.now();
final last = _lastGyroMs;
if (last != null) {
final dt =
(now.millisecondsSinceEpoch - last.millisecondsSinceEpoch) / 1000.0;
if (dt > 0 && dt < 0.5) {
_panRad += e.y * dt;
_tiltRad += e.x * dt;
_hasGyro = true;
final mag = e.x.abs() + e.y.abs() + e.z.abs();
if (mag > _recentOmega) _recentOmega = mag;
}
}
_lastGyroMs = now;
});
_accSub = accelerometerEventStream().listen((e) {
// 低通估重力方向 → 俯仰角(镜头朝上为正)
const a = 0.1;
_accLowZ = _accLowZ * (1 - a) + e.z * a;
_accLowY = _accLowY * (1 - a) + e.y * a;
_hasAcc = true;
});
}
void stop() {
_running = false;
_gyroSub?.cancel();
_accSub?.cancel();
_gyroSub = null;
_accSub = null;
}
/// 消费累计转角 → 缩略图像素位移 (dx, dy)。
/// dx>0 表示画面内容向右移动(前帧采样点左移补偿)。
(int, int) takeShift(double thumbW, double thumbH) {
final pan = _panRad;
final tilt = _tiltRad;
_panRad = 0;
_tiltRad = 0;
_recentOmega = 0;
if (!_hasGyro || (pan == 0 && tilt == 0)) return (0, 0);
final focalPx = (thumbH / 2) / math.tan(assumedVfovDeg * math.pi / 180 / 2);
final limit = math.max(thumbW, thumbH) / 4;
final dx = (kPanSign * pan * focalPx).clamp(-limit, limit).round();
final dy = (kTiltSign * tilt * focalPx).clamp(-limit, limit).round();
return (dx, dy);
}
/// 俯仰角(度):0=平举,正=镜头朝上。无加速度计数据返回 0。
double get pitchDeg {
if (!_hasAcc) return 0;
return math.atan2(_accLowY, _accLowZ) * 180 / math.pi;
}
/// 角速度是否持续偏大(走动/车载判定,供曝光联动等使用)
bool get moving => recentlyMoving;
}
@@ -18,7 +18,7 @@ class TfliteDetector {
static const int defaultInputSize = 1280;
// 目标数据置信度普遍偏低(0.1~0.2 量级),保留低分池供运动检测提升;
// 可运行时调整(设置页滑块),默认 0.10
double minScore = 0.10;
double minScore = 0.015;
static const double iouThreshold = 0.45;
static const int maxDetections = 20;
+1 -1
View File
@@ -2,7 +2,7 @@ name: observer
description: "视野 - 动物实时识别 (环颈雉鸡/生境), YOLOv8 + 充值付费"
publish_to: 'none'
version: 1.0.40+45
version: 1.0.42+47
environment:
sdk: ^3.12.2
@@ -90,4 +90,26 @@ void main() {
expect(vm.state.results.length, 1, reason: '同位置只有一条轨迹可见');
expect(vm.state.results.single.label, '斑鸠', reason: '框内容跟随后到的检测');
});
test('平卧长条框不被几何过滤(RNPHE_2030 实测框:归一化宽高比 3.99)', () async {
final vm = CameraViewModel(reminder: _RecordingReminder());
// 实测模型 0.8845 检出,框 0.283×0.071(像素 199×89,长尾雉鸡),
// 旧逻辑 aspect > 3.0 在建轨迹前丢弃 → 高分也无框
final flat = box('雉鸡', 0.88, 0.405, 0.419, 0.688, 0.490);
feed(vm, [flat]);
await Future<void>.delayed(const Duration(milliseconds: 600));
feed(vm, [flat]);
expect(vm.state.results.length, 1);
expect(vm.state.results.single.score, 0.88);
});
test('近距离大目标(占画面高 45%)与远距离小目标(高 0.8%)均可显示', () async {
final vm = CameraViewModel(reminder: _RecordingReminder());
final near = box('雉鸡', 0.9, 0.15, 0.05, 0.85, 0.50);
final far = box('雉鸡', 0.9, 0.45, 0.80, 0.48, 0.808);
feed(vm, [near, far]);
await Future<void>.delayed(const Duration(milliseconds: 600));
feed(vm, [near, far]);
expect(vm.state.results.length, 2);
});
}