coin-recog-demo/lib/services/coin_detector.dart
a1518 7cee2ae28f Initial commit: Coin scan demo with blur detection and coin contour cropping
- YOLOv5 TFLite coin detection with NMS
- Blur classifier for image quality gating
- Circular contour refinement and cropping
- Camera capture and gallery pick

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 20:35:28 -07:00

252 lines
7.3 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 '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 01).
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<void> ensureLoaded() async {
if (_interpreter != null || _loading) return;
_loading = true;
try {
_interpreter = await Interpreter.fromAsset(assetPath);
} finally {
_loading = false;
}
}
Future<List<CoinBox>> 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<double>.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 = <CoinBox>[];
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<CoinBox> 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<void> 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<CoinBox> _nms(List<CoinBox> boxes) {
final sorted = [...boxes]..sort((a, b) => b.confidence.compareTo(a.confidence));
final kept = <CoinBox>[];
final suppressed = List<bool>.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;
}