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 _colors = { 'pheasant': Color(0xFFE53935), 'suspect': Color(0xFFFDD835), }; static const _labels = {'pheasant': '野鸡', 'suspect': '疑似'}; /// 参考体型(米):野鸡身高 / 植被高度(参照旧 DistanceEstimator) static const _refSizeM = {'pheasant': 0.45, 'suspect': 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, ); final color = _colors[r.label] ?? Colors.white; final isSuspect = r.label == 'suspect'; 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 != '内置' ? '[${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 = _refSizeM[r.label]; if (refH == null) return ''; 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; }