coin-recog-demo/lib/services/coin_detector.dart

252 lines
7.3 KiB
Dart
Raw Permalink Normal View History

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;
}