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? 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.filled(rw * rh, 0); final rgb = List.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 = >[]; 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? 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.filled(rw * rh, 0); final rgb = List.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 _foregroundViaOtsu( List gray, int rw, int rh, int cx, int cy, ) { final blurred = _boxBlur3(gray, rw, rh); final thr = _otsu(blurred); final mask = List.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 _foregroundViaBorderFlood( List rgb, List gray, int rw, int rh, ) { // Median border luminance as table reference + RGB samples. final borderGray = []; final borderRgb = >[]; 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.filled(rw * rh, false); final qx = List.filled(rw * rh, 0); final qy = List.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.filled(rw * rh, false); for (var i = 0; i < fg.length; i++) { fg[i] = !bg[i]; } return fg; } static List? _quadFromMask( List 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 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 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.9–1.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 _padQuad( List 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 _boxBlur3(List src, int w, int h) { final tmp = List.filled(w * h, 0); final out = List.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 gray) { final hist = List.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? _largestComponent( List mask, int w, int h, int seedX, int seedY, ) { final seen = List.filled(w * h, false); List? bestEdge; var bestCount = 0; List? seededEdge; var seededCount = 0; final qx = List.filled(w * h, 0); final qy = List.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 = []; 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 _convexHull(List 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 = []; 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 = []; 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 = []; 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? _minAreaRect(List hull) { if (hull.length < 3) return null; var bestArea = double.infinity; List? 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 _orderCorners(List 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 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; } }