166 lines
5.1 KiB
Dart
166 lines
5.1 KiB
Dart
|
|
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();
|
|||
|
|
}
|