Files
observer/flutter_app/lib/camera/detection_overlay.dart
T
2026-09-03 10:01:42 +08:00

155 lines
5.1 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../detection/coordinate_mapper.dart';
import '../detection/detection_result.dart';
/// 检测框绘制分级:
/// - 目标物种 confirmed:红色实线 3px(强证据)
/// - 目标物种 candidate:红色虚线 2px 半透明(待确认,弱提示)
/// - 疑似(生境预警):黄色虚线 2px 半透明(常驻静态预警,弱化渲染)
/// 标签附带距离估计(针孔模型 焦距px×参考体型/框高px)。
/// 名称文本只取模型输出的 label(数据集训练决定,App 不内置物种名)。
class DetectionOverlay extends StatelessWidget {
final List<DetectionResult> results;
final int rotation;
final int imageWidthPx;
final int imageHeightPx;
const DetectionOverlay({
super.key,
required this.results,
required this.rotation,
required this.imageWidthPx,
required this.imageHeightPx,
});
@override
Widget build(BuildContext context) {
return IgnorePointer(
child: CustomPaint(
painter: _OverlayPainter(results, rotation, imageWidthPx, imageHeightPx),
child: const SizedBox.expand(),
),
);
}
}
class _OverlayPainter extends CustomPainter {
final List<DetectionResult> results;
final int rotation;
final int imageWidthPx;
final int imageHeightPx;
_OverlayPainter(this.results, this.rotation, this.imageWidthPx,
this.imageHeightPx);
// 仅保留通用「疑似」中文翻译;物种名由模型 labels 直接展示
// (识别什么物种由训练好的模型决定,App 不内置物种名)
static const _labels = {'suspect': '疑似'};
/// 参考体型(米):目标物种(class 0)身高 / suspect 植被高度
static const double _refSizeSpeciesM = 0.45;
static const double _refSizeSuspectM = 0.50;
/// iPhone 13 主摄在 1280 高预览下的估算焦距 px5.1mm / 5.30mm 传感器),
/// 单目误差 ±30%,仅作参考
static const double focalPx = 1230;
static const double maxDistanceM = 120;
@override
void paint(Canvas canvas, Size size) {
for (final r in results) {
final rect = CoordinateMapper.mapToView(
r.left,
r.top,
r.right,
r.bottom,
rotation,
imageWidthPx,
imageHeightPx,
size.width,
size.height,
);
// 颜色按类别索引而非 label 文本:模型类别名随数据集变化(物种中文名等),
// class 0 恒为目标物种(红),其余类恒为 suspect(黄)
final isSuspect = r.classId > 0 || r.label == 'suspect';
final color = isSuspect ? Color(0xFFFDD835) : Color(0xFFE53935);
final confirmed = r.confirmed && !isSuspect;
final paint = Paint()
..color = color.withValues(alpha: confirmed ? 1.0 : 0.55)
..style = PaintingStyle.stroke
..strokeWidth = confirmed ? 3 : 2
..isAntiAlias = true;
final box = Rect.fromLTRB(rect.left, rect.top, rect.right, rect.bottom);
if (confirmed) {
canvas.drawRect(box, paint);
} else {
_drawDashedRect(canvas, box, paint);
}
// 标签:框上方,含距离;多模型时标注来源模型名
final dist = _distanceLabel(r);
final modelTag = r.modelName.isNotEmpty
? '[${r.modelName}]'
: '';
final text =
'${_labels[r.label] ?? r.label}$modelTag ${(r.score * 100).toInt()}%$dist';
final textPainter = TextPainter(
text: TextSpan(
text: text,
style: TextStyle(
color: color.withValues(alpha: confirmed ? 1.0 : 0.8),
fontSize: 14,
fontWeight: FontWeight.w600,
shadows: const [Shadow(color: Colors.black, blurRadius: 3)],
),
),
textDirection: TextDirection.ltr,
)..layout();
final top = math.max(0.0, rect.top - 22);
final left = math.max(0.0, rect.left);
textPainter.paint(canvas, Offset(left + 4, top));
}
}
String _distanceLabel(DetectionResult r) {
final refH = (r.classId > 0 || r.label == 'suspect')
? _refSizeSuspectM
: _refSizeSpeciesM;
final hPx = r.height * imageHeightPx;
if (hPx < 8) return '';
final m = focalPx * refH / hPx;
if (m > maxDistanceM) return '';
return ' ≈${m.round()}m';
}
void _drawDashedRect(Canvas canvas, Rect r, Paint paint,
{double dash = 10, double gap = 6}) {
void dashLine(Offset a, Offset b) {
final total = (b - a).distance;
if (total <= 0) return;
final dir = (b - a) / total;
var d = 0.0;
while (d < total) {
final e = math.min(d + dash, total);
canvas.drawLine(a + dir * d, a + dir * e, paint);
d += dash + gap;
}
}
dashLine(r.topLeft, r.topRight);
dashLine(r.topRight, r.bottomRight);
dashLine(r.bottomRight, r.bottomLeft);
dashLine(r.bottomLeft, r.topLeft);
}
@override
bool shouldRepaint(_OverlayPainter oldDelegate) =>
oldDelegate.results != results ||
oldDelegate.rotation != rotation ||
oldDelegate.imageWidthPx != imageWidthPx ||
oldDelegate.imageHeightPx != imageHeightPx;
}