coin-recog-demo/lib/services/coin_scan_gate.dart
a1518 7cee2ae28f Initial commit: Coin scan demo with blur detection and coin contour cropping
- YOLOv5 TFLite coin detection with NMS
- Blur classifier for image quality gating
- Circular contour refinement and cropping
- Camera capture and gallery pick

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 20:35:28 -07:00

123 lines
3.5 KiB
Dart

import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:image/image.dart' as img;
import 'package:path_provider/path_provider.dart';
import '../widgets/guide_frame.dart';
import 'blur_detector.dart';
import 'coin_detector.dart';
enum CoinGateFail { none, blurry, noCoin }
class CoinGateResult {
const CoinGateResult({
required this.ok,
this.fail = CoinGateFail.none,
this.blur,
this.box,
});
final bool ok;
final CoinGateFail fail;
final BlurResult? blur;
final CoinBox? box;
}
/// Post-capture gate: blur.tflite → detect.tflite (CoinSnap order).
class CoinScanGate {
static Future<CoinGateResult> evaluate({
required String imagePath,
Size? captureViewport,
CoinDetector? detector,
BlurDetector? blurDetector,
}) async {
final ownedDet = detector == null;
final ownedBlur = blurDetector == null;
final det = detector ?? CoinDetector();
final blur = blurDetector ?? BlurDetector();
try {
final raster = await _guideCrop(imagePath, captureViewport);
if (raster == null) {
return const CoinGateResult(ok: false, fail: CoinGateFail.noCoin);
}
final blurResult = await blur.evaluate(raster);
if (blurResult.isBlurry) {
return CoinGateResult(
ok: false,
fail: CoinGateFail.blurry,
blur: blurResult,
);
}
final boxes = await det.detectImage(raster);
final best = det.pickBest(
boxes,
imageSize: Size(raster.width.toDouble(), raster.height.toDouble()),
);
if (best == null) {
return CoinGateResult(
ok: false,
fail: CoinGateFail.noCoin,
blur: blurResult,
);
}
return CoinGateResult(ok: true, blur: blurResult, box: best);
} finally {
if (ownedDet) await det.dispose();
if (ownedBlur) await blur.dispose();
}
}
static Future<img.Image?> _guideCrop(
String imagePath,
Size? captureViewport,
) async {
final raw = await File(imagePath).readAsBytes();
final codec = await ui.instantiateImageCodec(raw);
final frame = await codec.getNextFrame();
final fullImage = frame.image;
final rgba = await fullImage.toByteData(format: ui.ImageByteFormat.rawRgba);
if (rgba == null) {
fullImage.dispose();
return null;
}
final fullRaster = img.Image.fromBytes(
width: fullImage.width,
height: fullImage.height,
bytes: rgba.buffer,
bytesOffset: rgba.offsetInBytes,
numChannels: 4,
order: img.ChannelOrder.rgba,
);
fullImage.dispose();
final imageSize = Size(
fullRaster.width.toDouble(),
fullRaster.height.toDouble(),
);
final guide = captureViewport != null
? GuideFrame.inCoverImage(viewport: captureViewport, imageSize: imageSize)
: GuideFrame.inImage(imageSize);
final gx = guide.left.round().clamp(0, fullRaster.width - 1);
final gy = guide.top.round().clamp(0, fullRaster.height - 1);
final gw = guide.width.round().clamp(1, fullRaster.width - gx);
final gh = guide.height.round().clamp(1, fullRaster.height - gy);
return img.copyCrop(fullRaster, x: gx, y: gy, width: gw, height: gh);
}
/// Persist guide crop for preview reuse.
static Future<String> writeGuideJpeg(img.Image raster) async {
final dir = await getTemporaryDirectory();
final path =
'${dir.path}/coin_guide_${DateTime.now().millisecondsSinceEpoch}.jpg';
await File(path).writeAsBytes(img.encodeJpg(raster, quality: 95), flush: true);
return path;
}
}