card-recog-demo/lib/services/card_cropper.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

224 lines
6.5 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:io';
import 'dart:math' as math;
import 'dart:typed_data';
import 'dart:ui';
import 'package:image/image.dart' as img;
import 'card_detector.dart';
/// TCG card aspect (width / height), ~63×88 mm.
const double kCardAspect = 63 / 88;
class CropResult {
const CropResult({
required this.bytes,
required this.width,
required this.height,
required this.usedDetection,
});
final Uint8List bytes;
final int width;
final int height;
final bool usedDetection;
}
/// Crop + orientation correct from detection box (or guide fallback).
class CardCropper {
/// [box] in source image pixel coords. If null, uses centered guide.
/// When [box] has tilted [CardBox.corners], crops the quad interior
/// via perspective warp (exactly the green lock region).
static Future<CropResult> cropAndCorrect({
required String imagePath,
CardBox? box,
double padding = 0.06,
}) async {
return computeCrop(imagePath, box, padding);
}
static Future<CropResult> computeCrop(
String imagePath,
CardBox? box,
double padding,
) async {
final raw = await File(imagePath).readAsBytes();
var decoded = img.decodeImage(raw);
if (decoded == null) {
throw StateError('Failed to decode image: $imagePath');
}
// Bake EXIF orientation into pixels.
decoded = img.bakeOrientation(decoded);
final usedDetection = box != null;
img.Image out;
if (box != null && box.corners != null && box.corners!.length == 4) {
out = _perspectiveCrop(decoded, box.quad);
} else if (box != null) {
final w = decoded.width.toDouble();
final h = decoded.height.toDouble();
var cropRect = _expandAndClamp(box.rect, w, h, padding);
cropRect = _fitAspect(cropRect, w, h, kCardAspect);
out = _axisCrop(decoded, cropRect);
} else {
final w = decoded.width.toDouble();
final h = decoded.height.toDouble();
out = _axisCrop(decoded, _guideRect(w, h, kCardAspect));
}
// Mild straighten: if wider than tall but we expect portrait card, rotate.
if (out.width > out.height * 1.05) {
out = img.copyRotate(out, angle: 90);
}
// Keep high-res for sharp preview; only downscale huge images.
const maxSide = 2560;
if (out.width > maxSide || out.height > maxSide) {
if (out.width >= out.height) {
out = img.copyResize(
out,
width: maxSide,
interpolation: img.Interpolation.cubic,
);
} else {
out = img.copyResize(
out,
height: maxSide,
interpolation: img.Interpolation.cubic,
);
}
}
final bytes = Uint8List.fromList(img.encodeJpg(out, quality: 98));
return CropResult(
bytes: bytes,
width: out.width,
height: out.height,
usedDetection: usedDetection,
);
}
/// Perspective-warp the interior of TL→TR→BR→BL into a flat card image.
///
/// Corner order from detection is image-space; a tilted shot can map the
/// card's long edge as "top". Re-orient so output is always portrait.
static img.Image _perspectiveCrop(img.Image src, List<Offset> quad) {
var tl = quad[0];
var tr = quad[1];
var br = quad[2];
var bl = quad[3];
double avg(double a, double b) => (a + b) / 2;
var topW = avg((tr - tl).distance, (br - bl).distance);
var sideH = avg((bl - tl).distance, (br - tr).distance);
// Short edge = card width, long edge = card height (TCG portrait).
if (topW > sideH) {
final nTl = tr;
final nTr = br;
final nBr = bl;
final nBl = tl;
tl = nTl;
tr = nTr;
br = nBr;
bl = nBl;
final t = topW;
topW = sideH;
sideH = t;
}
// Prefer the edge that sits higher in the photo as the card top.
final topY = avg(tl.dy, tr.dy);
final botY = avg(bl.dy, br.dy);
if (topY > botY) {
final nTl = br;
final nTr = bl;
final nBr = tl;
final nBl = tr;
tl = nTl;
tr = nTr;
br = nBr;
bl = nBl;
}
var outW = topW.round().clamp(32, 4096);
var outH = sideH.round().clamp(32, 4096);
// Prefer portrait TCG aspect while preserving measured size scale.
const targetAspect = kCardAspect;
if (outW / outH > targetAspect * 1.08) {
outW = (outH * targetAspect).round().clamp(32, 4096);
} else if (outW / outH < targetAspect / 1.08) {
outH = (outW / targetAspect).round().clamp(32, 4096);
}
final dst = img.Image(width: outW, height: outH);
var out = img.copyRectify(
src,
topLeft: img.Point(tl.dx, tl.dy),
topRight: img.Point(tr.dx, tr.dy),
bottomLeft: img.Point(bl.dx, bl.dy),
bottomRight: img.Point(br.dx, br.dy),
interpolation: img.Interpolation.cubic,
toImage: dst,
);
// Safety net if mapping still came out landscape.
if (out.width > out.height * 1.05) {
out = img.copyRotate(out, angle: 90);
}
return out;
}
static img.Image _axisCrop(img.Image decoded, Rect cropRect) {
final x = cropRect.left.round().clamp(0, decoded.width - 1);
final y = cropRect.top.round().clamp(0, decoded.height - 1);
final cw = cropRect.width.round().clamp(1, decoded.width - x);
final ch = cropRect.height.round().clamp(1, decoded.height - y);
return img.copyCrop(decoded, x: x, y: y, width: cw, height: ch);
}
static Rect _expandAndClamp(Rect r, double w, double h, double pad) {
final dx = r.width * pad;
final dy = r.height * pad;
return Rect.fromLTRB(
(r.left - dx).clamp(0, w),
(r.top - dy).clamp(0, h),
(r.right + dx).clamp(0, w),
(r.bottom + dy).clamp(0, h),
);
}
/// Grow/shrink rect to match [aspect] (width/height), stay inside image.
static Rect _fitAspect(Rect r, double w, double h, double aspect) {
final cx = r.center.dx;
final cy = r.center.dy;
var rw = r.width;
var rh = r.height;
if (rw / rh > aspect) {
rh = rw / aspect;
} else {
rw = rh * aspect;
}
// If exceeds bounds, scale down.
final scale = math.min(1.0, math.min(w / rw, h / rh));
rw *= scale;
rh *= scale;
return Rect.fromCenter(center: Offset(cx, cy), width: rw, height: rh)
.intersect(Rect.fromLTWH(0, 0, w, h));
}
static Rect _guideRect(double w, double h, double aspect) {
// ~70% of the shorter side as card height.
final cardH = math.min(h * 0.72, w / aspect * 0.9);
final cardW = cardH * aspect;
return Rect.fromCenter(
center: Offset(w / 2, h / 2),
width: cardW,
height: cardH,
);
}
}