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

166 lines
5.1 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: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<Offset>? corners;
/// Effective lock quad (tilted when refined).
List<Offset> 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<Offset>? 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<List<CardBox>> detectFile(String path) async {
final input = InputImage.fromFilePath(path);
final objects = await _detector.processImage(input);
final boxes = <CardBox>[];
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<CardBox> 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 an aspect-normalized 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, clearCorners: true);
}
return box.copyWith(rect: normalized, corners: corners);
}
Future<void> dispose() => _detector.close();
}