card-recog-demo/lib/services/card_corner_refiner.dart
2026-07-14 22:51:07 -07:00

693 lines
21 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.18;
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);
final rgb = List<int>.filled(rw * rh * 3, 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);
final i = y * rw + x;
gray[i] = (0.299 * p.r + 0.587 * p.g + 0.114 * p.b).round();
rgb[i * 3] = p.r.toInt();
rgb[i * 3 + 1] = p.g.toInt();
rgb[i * 3 + 2] = p.b.toInt();
}
}
final cx = ((aabb.center.dx - x0).round()).clamp(0, rw - 1);
final cy = ((aabb.center.dy - y0).round()).clamp(0, rh - 1);
// Prefer flood-fill from ROI border (works on light tables); Otsu as backup.
final candidates = <List<Offset>>[];
final fromFlood = _quadFromMask(
_foregroundViaBorderFlood(rgb, gray, rw, rh),
rw,
rh,
cx,
cy,
x0,
y0,
);
if (fromFlood != null) candidates.add(fromFlood);
final fromOtsu = _quadFromMask(
_foregroundViaOtsu(gray, rw, rh, cx, cy),
rw,
rh,
cx,
cy,
x0,
y0,
);
if (fromOtsu != null) candidates.add(fromOtsu);
List<Offset>? best;
var bestScore = -1.0;
for (final q in candidates) {
if (!_looksLikeFullCard(q, aabb)) continue;
final score = _cardScore(q, aabb);
if (score > bestScore) {
bestScore = score;
best = q;
}
}
if (best == null) return null;
// Slight inset so perspective crop doesn't include table fringe.
return _padQuad(
best,
scale: 0.99,
topExtra: 0.0,
imageW: image.width.toDouble(),
imageH: image.height.toDouble(),
);
}
/// Tighten a loose AABB when tilted refine fails (axis-aligned shrink).
static Rect? shrinkAabb(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);
final rgb = List<int>.filled(rw * rh * 3, 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);
final i = y * rw + x;
gray[i] = (0.299 * p.r + 0.587 * p.g + 0.114 * p.b).round();
rgb[i * 3] = p.r.toInt();
rgb[i * 3 + 1] = p.g.toInt();
rgb[i * 3 + 2] = p.b.toInt();
}
}
final cx = ((aabb.center.dx - x0).round()).clamp(0, rw - 1);
final cy = ((aabb.center.dy - y0).round()).clamp(0, rh - 1);
final mask = _foregroundViaBorderFlood(rgb, gray, rw, rh);
final edge = _largestComponent(mask, rw, rh, cx, cy);
if (edge == null || edge.length < 40) return null;
var minX = edge.first.dx, maxX = edge.first.dx;
var minY = edge.first.dy, maxY = edge.first.dy;
for (final p in edge) {
if (p.dx < minX) minX = p.dx;
if (p.dx > maxX) maxX = p.dx;
if (p.dy < minY) minY = p.dy;
if (p.dy > maxY) maxY = p.dy;
}
// Map to image space and inset slightly.
final insetX = (maxX - minX) * 0.01;
final insetY = (maxY - minY) * 0.01;
final tight = Rect.fromLTRB(
x0 + minX + insetX,
y0 + minY + insetY,
x0 + maxX - insetX,
y0 + maxY - insetY,
);
final area = tight.width * tight.height;
final aabbArea = aabb.width * aabb.height;
if (area < aabbArea * 0.28 || area > aabbArea * 1.2) return null;
if (tight.width < 16 || tight.height < 16) return null;
return tight.intersect(Rect.fromLTWH(0, 0, image.width.toDouble(), image.height.toDouble()));
}
static List<bool> _foregroundViaOtsu(
List<int> gray,
int rw,
int rh,
int cx,
int cy,
) {
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;
}
if (!mask[cy * rw + cx]) {
for (var i = 0; i < mask.length; i++) {
mask[i] = !mask[i];
}
}
return mask;
}
/// Mark table/background by flooding from ROI borders; leftover = card.
static List<bool> _foregroundViaBorderFlood(
List<int> rgb,
List<int> gray,
int rw,
int rh,
) {
// Median border luminance as table reference + RGB samples.
final borderGray = <int>[];
final borderRgb = <List<int>>[];
void sample(int x, int y) {
final i = y * rw + x;
borderGray.add(gray[i]);
borderRgb.add([rgb[i * 3], rgb[i * 3 + 1], rgb[i * 3 + 2]]);
}
for (var x = 0; x < rw; x++) {
sample(x, 0);
sample(x, rh - 1);
}
for (var y = 1; y < rh - 1; y++) {
sample(0, y);
sample(rw - 1, y);
}
borderGray.sort();
final medG = borderGray[borderGray.length ~/ 2];
// Adaptive color distance: table is usually uniform.
var sumDev = 0.0;
for (final g in borderGray) {
sumDev += (g - medG).abs();
}
final mad = sumDev / borderGray.length;
final grayTol = math.max(18.0, mad * 3.5).clamp(18.0, 42.0);
const rgbTol = 48.0;
bool looksLikeBg(int i) {
final g = gray[i];
if ((g - medG).abs() > grayTol) return false;
final r = rgb[i * 3], gv = rgb[i * 3 + 1], b = rgb[i * 3 + 2];
// Compare to a few border samples near same luminance.
var best = double.infinity;
for (var k = 0; k < borderRgb.length; k += math.max(1, borderRgb.length ~/ 24)) {
final s = borderRgb[k];
final d = (r - s[0]).abs() + (gv - s[1]).abs() + (b - s[2]).abs();
if (d < best) best = d.toDouble();
}
return best <= rgbTol;
}
final bg = List<bool>.filled(rw * rh, false);
final qx = List<int>.filled(rw * rh, 0);
final qy = List<int>.filled(rw * rh, 0);
var head = 0;
var tail = 0;
void trySeed(int x, int y) {
final i = y * rw + x;
if (bg[i] || !looksLikeBg(i)) return;
bg[i] = true;
qx[tail] = x;
qy[tail] = y;
tail++;
}
for (var x = 0; x < rw; x++) {
trySeed(x, 0);
trySeed(x, rh - 1);
}
for (var y = 1; y < rh - 1; y++) {
trySeed(0, y);
trySeed(rw - 1, y);
}
while (head < tail) {
final x = qx[head];
final y = qy[head];
head++;
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 >= rw || ny >= rh) continue;
final ni = ny * rw + nx;
if (bg[ni] || !looksLikeBg(ni)) continue;
bg[ni] = true;
qx[tail] = nx;
qy[tail] = ny;
tail++;
}
}
// Foreground = not flooded background.
final fg = List<bool>.filled(rw * rh, false);
for (var i = 0; i < fg.length; i++) {
fg[i] = !bg[i];
}
return fg;
}
static List<Offset>? _quadFromMask(
List<bool> mask,
int rw,
int rh,
int cx,
int cy,
int x0,
int y0,
) {
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;
return _orderCorners([
for (final p in quad) Offset(x0 + p.dx, y0 + p.dy),
]);
}
/// Prefer portrait TCG aspect and tighter fit inside a possibly-loose AABB.
static double _cardScore(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;
final ratio = math.min(w, h) / math.max(w, h);
const target = 63 / 88;
final aspectScore = 1.0 - ((ratio - target).abs() / target).clamp(0.0, 1.0);
final aabbArea = aabb.width * aabb.height;
final area = _quadArea(q);
// Reward filling a reasonable portion without needing to match a loose ML box.
final fill = (area / aabbArea).clamp(0.0, 1.0);
final fillScore = fill < 0.35 ? fill / 0.35 : (fill > 0.95 ? 0.7 : 1.0);
return aspectScore * 0.6 + fillScore * 0.4;
}
/// TCG portrait ~63:88. Reject quads that only cover artwork / half a card.
///
/// ML Kit AABBs are often oversized; do not require the quad to fill most of
/// the AABB — only that it looks like a full portrait card near the box.
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);
// Allow tight cards inside loose ML boxes (was 0.55 — rejected good fits).
if (area < aabbArea * 0.28 || 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);
if (inter.width <= 0 || inter.height <= 0) return false;
final boundsArea = quadBounds.width * quadBounds.height;
if (boundsArea < 1) return false;
final cover = (inter.width * inter.height) / boundsArea;
// Quad should mostly sit inside the ML box (not a stray blob outside).
if (cover < 0.55) return false;
// Reject tiny upper-art-only locks relative to the detection box.
if ((maxY - minY) < aabb.height * 0.45) return false;
if ((maxX - minX) < aabb.width * 0.45) return false;
// Center of quad should stay near AABB center (blocks half-side locks).
final qcx = (minX + maxX) / 2;
final qcy = (minY + maxY) / 2;
if ((qcx - aabb.center.dx).abs() > aabb.width * 0.28) return false;
if ((qcy - aabb.center.dy).abs() > aabb.height * 0.28) return false;
return true;
}
/// Grow/shrink 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;
}
}