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)。 class DetectionOverlay extends StatelessWidget { final List 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 results; final int rotation; final int imageWidthPx; final int imageHeightPx; _OverlayPainter(this.results, this.rotation, this.imageWidthPx, this.imageHeightPx); static const _labels = {'pheasant': '环颈雉鸡', 'suspect': '疑似'}; /// 参考体型(米):目标物种(class 0,如环颈雉鸡)身高 / suspect 植被高度 static const double _refSizeSpeciesM = 0.45; static const double _refSizeSuspectM = 0.50; /// iPhone 13 主摄在 1280 高预览下的估算焦距 px(5.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; }