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

170 lines
5.2 KiB
Dart
Raw Permalink Normal View History

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 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<void> dispose() => _detector.close();
}