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 cropAndCorrect({ required String imagePath, CardBox? box, double padding = 0.06, }) async { return computeCrop(imagePath, box, padding); } static Future 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 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, ); } }