224 lines
6.5 KiB
Dart
224 lines
6.5 KiB
Dart
|
|
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,
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|