import 'dart:math' as math; import 'dart:ui'; import 'package:google_mlkit_object_detection/google_mlkit_object_detection.dart'; import 'package:image/image.dart' as img; import 'card_corner_refiner.dart'; /// TCG card aspect (width / height), ~63×88 mm. const double _kCardAspect = 63 / 88; /// Detected card-like region in image pixel coordinates. class CardBox { const CardBox({ required this.rect, required this.confidence, this.label, this.corners, }); /// Axis-aligned outer bounds (always set). final Rect rect; final double confidence; final String? label; /// Tilted card quad in image pixels, ordered TL → TR → BR → BL. /// When null, [rect] corners are used. final List? corners; /// Effective lock quad (tilted when refined). List get quad { final c = corners; if (c != null && c.length == 4) return c; return [ rect.topLeft, rect.topRight, rect.bottomRight, rect.bottomLeft, ]; } CardBox copyWith({ Rect? rect, double? confidence, String? label, List? corners, bool clearCorners = false, }) { return CardBox( rect: rect ?? this.rect, confidence: confidence ?? this.confidence, label: label ?? this.label, corners: clearCorners ? null : (corners ?? this.corners), ); } } /// ML Kit object detection on still images (post-capture lock). class CardDetector { CardDetector() { _detector = ObjectDetector( options: ObjectDetectorOptions( mode: DetectionMode.single, classifyObjects: true, multipleObjects: true, ), ); } late final ObjectDetector _detector; Future> detectFile(String path) async { final input = InputImage.fromFilePath(path); final objects = await _detector.processImage(input); final boxes = []; for (final obj in objects) { final conf = obj.labels.isEmpty ? 0.5 : obj.labels .map((l) => l.confidence) .reduce((a, b) => a > b ? a : b); final label = obj.labels.isEmpty ? null : obj.labels.first.text; boxes.add( CardBox( rect: Rect.fromLTRB( obj.boundingBox.left.toDouble(), obj.boundingBox.top.toDouble(), obj.boundingBox.right.toDouble(), obj.boundingBox.bottom.toDouble(), ), confidence: conf, label: label, ), ); } boxes.sort((a, b) => b.confidence.compareTo(a.confidence)); return boxes; } /// Prefer largest reasonably confident box; bias toward TCG portrait aspect. CardBox? pickBest(List boxes, {Size? imageSize}) { if (boxes.isEmpty) return null; CardBox? best; var bestScore = -1.0; for (final b in boxes) { final area = b.rect.width * b.rect.height; final imgArea = imageSize == null ? area : (imageSize.width * imageSize.height).clamp(1.0, double.infinity); final fill = area / imgArea; if (fill < 0.04 || fill > 0.98) continue; final aspect = b.rect.width / b.rect.height; final short = math.min(aspect, 1 / aspect); final long = math.max(aspect, 1 / aspect); final ratio = short / long; // Closer to 63/88 (~0.72) scores higher; square half-art scores lower. final aspectScore = 1.0 - ((ratio - _kCardAspect).abs() / _kCardAspect).clamp(0.0, 1.0); final score = b.confidence * 0.3 + fill * 0.4 + aspectScore * 0.3; if (score > bestScore) { bestScore = score; best = b; } } return best ?? boxes.first; } /// When ML only framed the colorful upper half, grow height to portrait TCG. static Rect expandToCardAspect(Rect r, Size imageSize) { final aspect = r.width / math.max(r.height, 1.0); // Already tall enough for a portrait card. if (aspect <= _kCardAspect * 1.12) { return r.intersect(Rect.fromLTWH(0, 0, imageSize.width, imageSize.height)); } final targetH = r.width / _kCardAspect; // Keep the detected top (art header) and extend downward into the text box. var top = r.top; var bottom = top + targetH; if (bottom > imageSize.height) { bottom = imageSize.height; top = (bottom - targetH).clamp(0.0, imageSize.height); } if (top < 0) { top = 0; bottom = math.min(imageSize.height, targetH); } return Rect.fromLTRB(r.left, top, r.right, bottom) .intersect(Rect.fromLTWH(0, 0, imageSize.width, imageSize.height)); } /// Refine [box] into a tilted quad using pixel edges inside the AABB. /// Falls back to a content-shrunk AABB when the blob looks like half a card. CardBox refineCorners(img.Image image, CardBox box) { final imageSize = Size(image.width.toDouble(), image.height.toDouble()); final normalized = expandToCardAspect(box.rect, imageSize); final corners = CardCornerRefiner.refine(image, normalized); if (corners != null) { return box.copyWith(rect: normalized, corners: corners); } final shrunk = CardCornerRefiner.shrinkAabb(image, normalized); if (shrunk != null) { return box.copyWith(rect: shrunk, clearCorners: true); } return box.copyWith(rect: normalized, clearCorners: true); } Future dispose() => _detector.close(); }