- 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>
252 lines
7.3 KiB
Dart
252 lines
7.3 KiB
Dart
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<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;
|
||
}
|