card-recog-demo/lib/services/card_cropper.dart

219 lines
6.5 KiB
Dart
Raw Permalink Normal View History

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.0,
}) 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);
}
// Resize to consistent card-proportional size so hash is scale-invariant.
const cropW = 280;
const cropH = 390;
out = img.copyResize(out, width: cropW, height: cropH,
interpolation: img.Interpolation.cubic);
// Trim border fringe so desktop/table edges don't leak into the hash.
const borderTrim = 10;
out = img.copyCrop(out,
x: borderTrim,
y: borderTrim,
width: cropW - borderTrim * 2,
height: cropH - borderTrim * 2);
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),
);
}
/// Shrink the oversized side to match [aspect] (avoid growing into background).
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) {
rw = rh * aspect;
} else {
rh = rw / aspect;
}
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,
);
}
}