coin-recog-demo/lib/services/coin_contour_refiner.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

97 lines
2.9 KiB
Dart

import 'dart:math' as math;
import 'dart:ui';
import 'package:image/image.dart' as img;
class RefinedCircle {
const RefinedCircle({required this.center, required this.radius});
final Offset center;
final double radius;
}
/// Refine an AABB into a circular coin contour via radial edge search.
class CoinContourRefiner {
static RefinedCircle? refine(img.Image image, Rect aabb) {
if (aabb.width < 16 || aabb.height < 16) return null;
final cx = aabb.center.dx;
final cy = aabb.center.dy;
final maxR = math.max(aabb.width, aabb.height) * 0.62;
final minR = math.min(aabb.width, aabb.height) * 0.28;
if (maxR <= minR + 2) {
return RefinedCircle(
center: Offset(cx, cy),
radius: math.min(aabb.width, aabb.height) / 2,
);
}
// Sample edge strength along rays; vote for radius with strongest ring.
const rays = 64;
final radiusVotes = <double>[];
for (var i = 0; i < rays; i++) {
final ang = (i / rays) * math.pi * 2;
final dx = math.cos(ang);
final dy = math.sin(ang);
var bestR = (minR + maxR) / 2;
var bestMag = -1.0;
for (var r = minR; r <= maxR; r += 1.5) {
final x1 = (cx + dx * r).round();
final y1 = (cy + dy * r).round();
final x0 = (cx + dx * (r - 2)).round();
final y0 = (cy + dy * (r - 2)).round();
if (!_in(image, x0, y0) || !_in(image, x1, y1)) continue;
final g0 = _gray(image, x0, y0);
final g1 = _gray(image, x1, y1);
final mag = (g1 - g0).abs();
if (mag > bestMag) {
bestMag = mag;
bestR = r;
}
}
if (bestMag > 8) radiusVotes.add(bestR);
}
if (radiusVotes.length < rays * 0.35) {
return RefinedCircle(
center: Offset(cx, cy),
radius: math.min(aabb.width, aabb.height) / 2 * 0.96,
);
}
radiusVotes.sort();
final radius = radiusVotes[radiusVotes.length ~/ 2] * 1.04;
// Slight center nudge from opposite ray midpoints.
var nx = 0.0;
var ny = 0.0;
var n = 0;
for (var i = 0; i < rays; i += 2) {
final ang = (i / rays) * math.pi * 2;
final r = radiusVotes[math.min(i, radiusVotes.length - 1)];
nx += cx + math.cos(ang) * (r - radius);
ny += cy + math.sin(ang) * (r - radius);
n++;
}
final center = n == 0
? Offset(cx, cy)
: Offset(
((nx / n) + cx) / 2,
((ny / n) + cy) / 2,
);
final clamped = Offset(
center.dx.clamp(radius, image.width - radius),
center.dy.clamp(radius, image.height - radius),
);
return RefinedCircle(center: clamped, radius: radius);
}
static bool _in(img.Image image, int x, int y) =>
x >= 0 && y >= 0 && x < image.width && y < image.height;
static double _gray(img.Image image, int x, int y) {
final p = image.getPixel(x, y);
return 0.299 * p.r + 0.587 * p.g + 0.114 * p.b;
}
}