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 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 _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 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; } }