card-recog-demo/lib/services/card_corner_refiner.dart
a1518 ccf8eeb21d Initial commit: Flutter card recognition demo
Camera capture, corner detection/refinement, and preview crop pipeline for trading cards.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 20:17:32 -07:00

485 lines
14 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:ui';
import 'package:image/image.dart' as img;
/// Refine an axis-aligned detection into a tilted card quadrilateral.
///
/// Returns corners ordered TL → TR → BR → BL in full-image pixel coords,
/// or `null` if refinement fails (caller should keep the AABB).
class CardCornerRefiner {
/// [aabb] is the ML Kit box in the same pixel space as [image].
static List<Offset>? refine(img.Image image, Rect aabb) {
final pad = math.max(aabb.width, aabb.height) * 0.12;
final roi = Rect.fromLTRB(
(aabb.left - pad).clamp(0, image.width - 1.0),
(aabb.top - pad).clamp(0, image.height - 1.0),
(aabb.right + pad).clamp(1.0, image.width.toDouble()),
(aabb.bottom + pad).clamp(1.0, image.height.toDouble()),
);
final x0 = roi.left.floor();
final y0 = roi.top.floor();
final rw = math.max(1, roi.width.ceil());
final rh = math.max(1, roi.height.ceil());
if (rw < 24 || rh < 24) return null;
final gray = List<int>.filled(rw * rh, 0);
for (var y = 0; y < rh; y++) {
final sy = (y0 + y).clamp(0, image.height - 1);
for (var x = 0; x < rw; x++) {
final sx = (x0 + x).clamp(0, image.width - 1);
final p = image.getPixel(sx, sy);
gray[y * rw + x] = (0.299 * p.r + 0.587 * p.g + 0.114 * p.b).round();
}
}
// Light blur to stabilize edges.
final blurred = _boxBlur3(gray, rw, rh);
final thr = _otsu(blurred);
final mask = List<bool>.filled(rw * rh, false);
for (var i = 0; i < blurred.length; i++) {
mask[i] = blurred[i] >= thr;
}
// Prefer polarity where the center of the AABB is foreground.
final cx = ((aabb.center.dx - x0).round()).clamp(0, rw - 1);
final cy = ((aabb.center.dy - y0).round()).clamp(0, rh - 1);
if (!mask[cy * rw + cx]) {
for (var i = 0; i < mask.length; i++) {
mask[i] = !mask[i];
}
}
final component = _largestComponent(mask, rw, rh, cx, cy);
if (component == null || component.length < 40) return null;
final hull = _convexHull(component);
if (hull.length < 3) return null;
final quad = _minAreaRect(hull);
if (quad == null) return null;
// Map back to full-image coords and order TL, TR, BR, BL.
var ordered = _orderCorners([
for (final p in quad) Offset(x0 + p.dx, y0 + p.dy),
]);
// Reject half-card blobs (e.g. Energy art only) before padding.
if (!_looksLikeFullCard(ordered, aabb)) return null;
// Light outward pad so the physical border isn't clipped (avoid big gaps).
ordered = _padQuad(
ordered,
scale: 1.05,
topExtra: 0.03,
imageW: image.width.toDouble(),
imageH: image.height.toDouble(),
);
// Only pull top up if it still sits clearly below the ML box top.
ordered = _coverAabbTop(
ordered,
aabb,
image.width.toDouble(),
image.height.toDouble(),
);
if (!_looksLikeFullCard(ordered, aabb)) return null;
return ordered;
}
/// TCG portrait ~63:88. Reject quads that only cover artwork / half the AABB.
static bool _looksLikeFullCard(List<Offset> q, Rect aabb) {
final topW = (q[1] - q[0]).distance;
final botW = (q[2] - q[3]).distance;
final leftH = (q[3] - q[0]).distance;
final rightH = (q[2] - q[1]).distance;
final w = (topW + botW) / 2;
final h = (leftH + rightH) / 2;
if (w < 8 || h < 8) return false;
final short = math.min(w, h);
final long = math.max(w, h);
final ratio = short / long;
// Portrait card ≈ 0.72; half-art blobs are often nearer square (~0.91.0).
if (ratio < 0.55 || ratio > 0.88) return false;
final aabbArea = aabb.width * aabb.height;
if (aabbArea < 1) return false;
final area = _quadArea(q);
if (area < aabbArea * 0.55 || area > aabbArea * 1.55) return false;
var minY = q[0].dy, maxY = q[0].dy;
var minX = q[0].dx, maxX = q[0].dx;
for (final p in q) {
if (p.dy < minY) minY = p.dy;
if (p.dy > maxY) maxY = p.dy;
if (p.dx < minX) minX = p.dx;
if (p.dx > maxX) maxX = p.dx;
}
final quadBounds = Rect.fromLTRB(minX, minY, maxX, maxY);
final inter = quadBounds.intersect(aabb);
final cover =
(inter.width * inter.height) / aabbArea;
if (cover < 0.65) return false;
// Must span most of the detection height (blocks upper-half-only locks).
if ((maxY - minY) < aabb.height * 0.72) return false;
return true;
}
/// Extend TL/TR when ML Kit's box still reaches above the refined top.
static List<Offset> _coverAabbTop(
List<Offset> q,
Rect aabb,
double imageW,
double imageH,
) {
final topY = (q[0].dy + q[1].dy) / 2;
final targetTop = aabb.top;
final need = topY - targetTop;
// Ignore tiny gaps; only fix real header clipping.
if (need < aabb.height * 0.02) return q;
final topMid = Offset((q[0].dx + q[1].dx) / 2, (q[0].dy + q[1].dy) / 2);
final botMid = Offset((q[2].dx + q[3].dx) / 2, (q[2].dy + q[3].dy) / 2);
final up = topMid - botMid;
final len = up.distance;
if (len < 1) return q;
final bump = up * (need / len);
Offset clamp(Offset p) => Offset(
p.dx.clamp(0.0, imageW - 1),
p.dy.clamp(0.0, imageH - 1),
);
return [
clamp(q[0] + bump),
clamp(q[1] + bump),
q[2],
q[3],
];
}
/// Grow quad from its center; [topExtra] adds more margin on TL/TR.
static List<Offset> _padQuad(
List<Offset> q, {
required double scale,
required double topExtra,
required double imageW,
required double imageH,
}) {
final c = Offset(
(q[0].dx + q[1].dx + q[2].dx + q[3].dx) / 4,
(q[0].dy + q[1].dy + q[2].dy + q[3].dy) / 4,
);
final expanded = [
for (final p in q)
Offset(
c.dx + (p.dx - c.dx) * scale,
c.dy + (p.dy - c.dy) * scale,
),
];
// Push top edge further along card height (away from bottom).
final topMid = Offset(
(expanded[0].dx + expanded[1].dx) / 2,
(expanded[0].dy + expanded[1].dy) / 2,
);
final botMid = Offset(
(expanded[2].dx + expanded[3].dx) / 2,
(expanded[2].dy + expanded[3].dy) / 2,
);
final up = topMid - botMid;
final bump = Offset(up.dx * topExtra, up.dy * topExtra);
expanded[0] = expanded[0] + bump;
expanded[1] = expanded[1] + bump;
Offset clamp(Offset p) => Offset(
p.dx.clamp(0.0, imageW - 1),
p.dy.clamp(0.0, imageH - 1),
);
return expanded.map(clamp).toList(growable: false);
}
static List<int> _boxBlur3(List<int> src, int w, int h) {
final tmp = List<int>.filled(w * h, 0);
final out = List<int>.filled(w * h, 0);
for (var y = 0; y < h; y++) {
for (var x = 0; x < w; x++) {
var s = 0;
var n = 0;
for (var dx = -1; dx <= 1; dx++) {
final xx = x + dx;
if (xx < 0 || xx >= w) continue;
s += src[y * w + xx];
n++;
}
tmp[y * w + x] = s ~/ n;
}
}
for (var y = 0; y < h; y++) {
for (var x = 0; x < w; x++) {
var s = 0;
var n = 0;
for (var dy = -1; dy <= 1; dy++) {
final yy = y + dy;
if (yy < 0 || yy >= h) continue;
s += tmp[yy * w + x];
n++;
}
out[y * w + x] = s ~/ n;
}
}
return out;
}
static int _otsu(List<int> gray) {
final hist = List<int>.filled(256, 0);
for (final v in gray) {
hist[v.clamp(0, 255)]++;
}
final total = gray.length;
var sum = 0;
for (var i = 0; i < 256; i++) {
sum += i * hist[i];
}
var sumB = 0;
var wB = 0;
var best = 0.0;
var thr = 128;
for (var t = 0; t < 256; t++) {
wB += hist[t];
if (wB == 0) continue;
final wF = total - wB;
if (wF == 0) break;
sumB += t * hist[t];
final mB = sumB / wB;
final mF = (sum - sumB) / wF;
final between = wB * wF * (mB - mF) * (mB - mF);
if (between > best) {
best = between;
thr = t;
}
}
return thr;
}
/// BFS largest component; prefer one covering seed if large enough.
static List<Offset>? _largestComponent(
List<bool> mask,
int w,
int h,
int seedX,
int seedY,
) {
final seen = List<bool>.filled(w * h, false);
List<Offset>? bestEdge;
var bestCount = 0;
List<Offset>? seededEdge;
var seededCount = 0;
final qx = List<int>.filled(w * h, 0);
final qy = List<int>.filled(w * h, 0);
for (var sy = 0; sy < h; sy++) {
for (var sx = 0; sx < w; sx++) {
final start = sy * w + sx;
if (!mask[start] || seen[start]) continue;
var head = 0;
var tail = 0;
qx[tail] = sx;
qy[tail] = sy;
tail++;
seen[start] = true;
final edgePts = <Offset>[];
var containsSeed = false;
var count = 0;
while (head < tail) {
final x = qx[head];
final y = qy[head];
head++;
count++;
var onEdge = false;
for (final d in const [
[-1, 0],
[1, 0],
[0, -1],
[0, 1],
]) {
final nx = x + d[0];
final ny = y + d[1];
if (nx < 0 ||
ny < 0 ||
nx >= w ||
ny >= h ||
!mask[ny * w + nx]) {
onEdge = true;
break;
}
}
if (onEdge) edgePts.add(Offset(x.toDouble(), y.toDouble()));
if (x == seedX && y == seedY) containsSeed = true;
for (final d in const [
[-1, 0],
[1, 0],
[0, -1],
[0, 1],
]) {
final nx = x + d[0];
final ny = y + d[1];
if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue;
final ni = ny * w + nx;
if (!mask[ni] || seen[ni]) continue;
seen[ni] = true;
qx[tail] = nx;
qy[tail] = ny;
tail++;
}
}
if (edgePts.length < 8) continue;
if (count > bestCount) {
bestCount = count;
bestEdge = edgePts;
}
if (containsSeed) {
seededCount = count;
seededEdge = edgePts;
}
}
}
if (seededEdge != null && seededCount >= bestCount * 0.35) {
return seededEdge;
}
return bestEdge;
}
static List<Offset> _convexHull(List<Offset> pts) {
if (pts.length <= 2) return List.of(pts);
final sorted = [...pts]..sort((a, b) {
final c = a.dx.compareTo(b.dx);
return c != 0 ? c : a.dy.compareTo(b.dy);
});
// Deduplicate densely: subsample for speed on large blobs.
final sample = <Offset>[];
final step = math.max(1, sorted.length ~/ 800);
for (var i = 0; i < sorted.length; i += step) {
sample.add(sorted[i]);
}
if (sample.last != sorted.last) sample.add(sorted.last);
double cross(Offset o, Offset a, Offset b) =>
(a.dx - o.dx) * (b.dy - o.dy) - (a.dy - o.dy) * (b.dx - o.dx);
final lower = <Offset>[];
for (final p in sample) {
while (lower.length >= 2 &&
cross(lower[lower.length - 2], lower[lower.length - 1], p) <= 0) {
lower.removeLast();
}
lower.add(p);
}
final upper = <Offset>[];
for (final p in sample.reversed) {
while (upper.length >= 2 &&
cross(upper[upper.length - 2], upper[upper.length - 1], p) <= 0) {
upper.removeLast();
}
upper.add(p);
}
lower.removeLast();
upper.removeLast();
return [...lower, ...upper];
}
/// Rotating-calipers style min-area rectangle from convex hull.
static List<Offset>? _minAreaRect(List<Offset> hull) {
if (hull.length < 3) return null;
var bestArea = double.infinity;
List<Offset>? best;
for (var i = 0; i < hull.length; i++) {
final a = hull[i];
final b = hull[(i + 1) % hull.length];
final edge = b - a;
final len = edge.distance;
if (len < 1e-6) continue;
final ux = edge.dx / len;
final uy = edge.dy / len;
final vx = -uy;
final vy = ux;
var minU = double.infinity, maxU = -double.infinity;
var minV = double.infinity, maxV = -double.infinity;
for (final p in hull) {
final u = (p.dx - a.dx) * ux + (p.dy - a.dy) * uy;
final v = (p.dx - a.dx) * vx + (p.dy - a.dy) * vy;
if (u < minU) minU = u;
if (u > maxU) maxU = u;
if (v < minV) minV = v;
if (v > maxV) maxV = v;
}
final area = (maxU - minU) * (maxV - minV);
if (area < bestArea) {
bestArea = area;
// Corners in edge-aligned space → image space.
Offset corner(double u, double v) => Offset(
a.dx + u * ux + v * vx,
a.dy + u * uy + v * vy,
);
best = [
corner(minU, minV),
corner(maxU, minV),
corner(maxU, maxV),
corner(minU, maxV),
];
}
}
return best;
}
static List<Offset> _orderCorners(List<Offset> corners) {
assert(corners.length == 4);
final c = Offset(
(corners[0].dx + corners[1].dx + corners[2].dx + corners[3].dx) / 4,
(corners[0].dy + corners[1].dy + corners[2].dy + corners[3].dy) / 4,
);
final sorted = [...corners]
..sort((a, b) {
final aa = math.atan2(a.dy - c.dy, a.dx - c.dx);
final bb = math.atan2(b.dy - c.dy, b.dx - c.dx);
return aa.compareTo(bb);
});
// After atan2 sort (CCW from +x), pick the top-left-most as start.
var start = 0;
var bestScore = double.infinity;
for (var i = 0; i < 4; i++) {
final score = sorted[i].dx + sorted[i].dy;
if (score < bestScore) {
bestScore = score;
start = i;
}
}
return [
sorted[start],
sorted[(start + 1) % 4],
sorted[(start + 2) % 4],
sorted[(start + 3) % 4],
];
}
static double _quadArea(List<Offset> q) {
// Shoelace.
var s = 0.0;
for (var i = 0; i < 4; i++) {
final a = q[i];
final b = q[(i + 1) % 4];
s += a.dx * b.dy - b.dx * a.dy;
}
return s.abs() / 2;
}
}