693 lines
21 KiB
Dart
693 lines
21 KiB
Dart
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.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<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;
|
||
}
|
||
}
|