import 'dart:math' as math; import 'dart:typed_data'; import 'dart:ui'; import 'package:image/image.dart' as img; import 'package:tflite_flutter/tflite_flutter.dart'; import 'coin_contour_refiner.dart'; /// Detected coin region in image pixel coordinates. class CoinBox { const CoinBox({ required this.rect, required this.confidence, this.center, this.radius, }); /// Axis-aligned outer bounds. final Rect rect; final double confidence; /// Refined circle (image pixels). When null, use [rect] inscribed circle. final Offset? center; final double? radius; Offset get effectiveCenter => center ?? Offset(rect.center.dx, rect.center.dy); double get effectiveRadius => radius ?? (math.min(rect.width, rect.height) / 2); CoinBox copyWith({ Rect? rect, double? confidence, Offset? center, double? radius, }) { return CoinBox( rect: rect ?? this.rect, confidence: confidence ?? this.confidence, center: center ?? this.center, radius: radius ?? this.radius, ); } } /// CoinSnap-style YOLOv5 detector (`assets/ml/detect.tflite`). /// /// Input: `[1, 320, 320, 3]` float32, pixels / 255, letterbox resize. /// Output: `[1, 6300, 6]` → `cx, cy, w, h, obj, cls` (already 0–1). class CoinDetector { CoinDetector(); static const int inputSize = 320; static const double confThreshold = 0.35; static const double iouThreshold = 0.45; static const String assetPath = 'assets/ml/detect.tflite'; Interpreter? _interpreter; bool _loading = false; Future ensureLoaded() async { if (_interpreter != null || _loading) return; _loading = true; try { _interpreter = await Interpreter.fromAsset(assetPath); } finally { _loading = false; } } Future> detectImage(img.Image image) async { await ensureLoaded(); final interp = _interpreter; if (interp == null) return const []; final prepared = _letterbox(image, inputSize); final input = _toInputTensor(prepared.canvas); // Nested list matches [1, 6300, 6] expected by tflite_flutter. final output = List.generate( 1, (_) => List.generate(6300, (_) => List.filled(6, 0.0)), ); final inputNd = List.generate( 1, (_) => List.generate( inputSize, (y) => List.generate( inputSize, (x) { final i = (y * inputSize + x) * 3; return [input[i], input[i + 1], input[i + 2]]; }, ), ), ); interp.run(inputNd, output); final boxes = []; final rows = output[0]; for (final row in rows) { final score = row[4] * row[5]; if (score < confThreshold) continue; // Normalized cx,cy,w,h on the letterboxed 320 canvas. final cx = row[0] * inputSize; final cy = row[1] * inputSize; final w = row[2] * inputSize; final h = row[3] * inputSize; // Map back from letterbox → original image pixels. final left = (cx - w / 2 - prepared.padX) / prepared.scale; final top = (cy - h / 2 - prepared.padY) / prepared.scale; final right = (cx + w / 2 - prepared.padX) / prepared.scale; final bottom = (cy + h / 2 - prepared.padY) / prepared.scale; final rect = Rect.fromLTRB(left, top, right, bottom).intersect( Rect.fromLTWH(0, 0, image.width.toDouble(), image.height.toDouble()), ); if (rect.width < 8 || rect.height < 8) continue; boxes.add(CoinBox(rect: rect, confidence: score)); } return _nms(boxes); } /// Prefer largest reasonably confident near-circular box. CoinBox? pickBest(List boxes, {Size? imageSize}) { if (boxes.isEmpty) return null; CoinBox? best; var bestScore = -1.0; for (final b in boxes) { final area = b.rect.width * b.rect.height; final imgArea = imageSize == null ? area : (imageSize.width * imageSize.height).clamp(1.0, double.infinity); final fill = area / imgArea; if (fill < 0.02 || fill > 0.95) continue; final aspect = b.rect.width / math.max(b.rect.height, 1.0); final roundScore = 1.0 - (aspect - 1.0).abs().clamp(0.0, 1.0); final score = b.confidence * 0.45 + fill * 0.25 + roundScore * 0.3; if (score > bestScore) { bestScore = score; best = b; } } return best ?? boxes.first; } /// Refine AABB into a circle using local edge intensity. CoinBox refineContour(img.Image image, CoinBox box) { final refined = CoinContourRefiner.refine(image, box.rect); if (refined == null) { final r = math.min(box.rect.width, box.rect.height) / 2; return box.copyWith(center: box.rect.center, radius: r); } final side = refined.radius * 2; final rect = Rect.fromCenter( center: refined.center, width: side, height: side, ).intersect( Rect.fromLTWH(0, 0, image.width.toDouble(), image.height.toDouble()), ); return box.copyWith( rect: rect, center: refined.center, radius: refined.radius, ); } Future dispose() async { _interpreter?.close(); _interpreter = null; } static Float32List _toInputTensor(img.Image canvas) { final out = Float32List(1 * inputSize * inputSize * 3); var i = 0; for (var y = 0; y < inputSize; y++) { for (var x = 0; x < inputSize; x++) { final p = canvas.getPixel(x, y); out[i++] = p.r / 255.0; out[i++] = p.g / 255.0; out[i++] = p.b / 255.0; } } return out; } static _Letterbox _letterbox(img.Image src, int size) { final scale = math.min(size / src.width, size / src.height); final nw = math.max(1, (src.width * scale).round()); final nh = math.max(1, (src.height * scale).round()); final resized = img.copyResize( src, width: nw, height: nh, interpolation: img.Interpolation.linear, ); final canvas = img.Image(width: size, height: size); img.fill(canvas, color: img.ColorRgb8(114, 114, 114)); final padX = ((size - nw) / 2).floor(); final padY = ((size - nh) / 2).floor(); img.compositeImage(canvas, resized, dstX: padX, dstY: padY); return _Letterbox(canvas: canvas, scale: scale, padX: padX.toDouble(), padY: padY.toDouble()); } static List _nms(List boxes) { final sorted = [...boxes]..sort((a, b) => b.confidence.compareTo(a.confidence)); final kept = []; final suppressed = List.filled(sorted.length, false); for (var i = 0; i < sorted.length; i++) { if (suppressed[i]) continue; kept.add(sorted[i]); for (var j = i + 1; j < sorted.length; j++) { if (suppressed[j]) continue; if (_iou(sorted[i].rect, sorted[j].rect) > iouThreshold) { suppressed[j] = true; } } } return kept; } static double _iou(Rect a, Rect b) { final inter = a.intersect(b); if (inter.isEmpty) return 0; final interArea = inter.width * inter.height; final union = a.width * a.height + b.width * b.height - interArea; return union <= 0 ? 0 : interArea / union; } } class _Letterbox { const _Letterbox({ required this.canvas, required this.scale, required this.padX, required this.padY, }); final img.Image canvas; final double scale; final double padX; final double padY; }