coin-recog-demo/lib/services/blur_detector.dart

118 lines
3.4 KiB
Dart
Raw Permalink Normal View History

import 'dart:typed_data';
import 'package:image/image.dart' as img;
import 'package:tflite_flutter/tflite_flutter.dart';
/// Blur / sharpness result from CoinSnap `blur.tflite`.
class BlurResult {
const BlurResult({
required this.blurScore,
required this.clearScore,
required this.isBlurry,
});
/// Softmax-ish class 0 = blur, class 1 = clear (from model).
final double blurScore;
final double clearScore;
final bool isBlurry;
double get confidence => isBlurry ? blurScore : clearScore;
}
/// CoinSnap blur classifier (`assets/ml/blur.tflite`).
///
/// Input: `[1, 3, 224, 224]` NCHW float32, letterbox on white, pixels / 255.
/// Output: `[1, 2]` → `[blur, clear]`.
class BlurDetector {
BlurDetector({this.clearThreshold = 0.55});
static const int inputSize = 224;
static const String assetPath = 'assets/ml/blur.tflite';
/// Clear score must be ≥ this to pass (mirrors app-side threshold).
final double clearThreshold;
Interpreter? _interpreter;
bool _loading = false;
Future<void> ensureLoaded() async {
if (_interpreter != null || _loading) return;
_loading = true;
try {
_interpreter = await Interpreter.fromAsset(assetPath);
} finally {
_loading = false;
}
}
Future<BlurResult> evaluate(img.Image image) async {
await ensureLoaded();
final interp = _interpreter;
if (interp == null) {
return const BlurResult(blurScore: 0, clearScore: 1, isBlurry: false);
}
final canvas = _letterboxWhite(image, inputSize);
final flat = _toNchw(canvas);
// Nested list matches [1, 3, 224, 224] NCHW.
final inputNd = List.generate(
1,
(_) => List.generate(
3,
(c) => List.generate(
inputSize,
(y) => List.generate(inputSize, (x) {
final i = c * inputSize * inputSize + y * inputSize + x;
return flat[i];
}),
),
),
);
final output = List.generate(1, (_) => List<double>.filled(2, 0.0));
interp.run(inputNd, output);
final blur = output[0][0];
final clear = output[0][1];
final isBlurry = clear < clearThreshold;
return BlurResult(blurScore: blur, clearScore: clear, isBlurry: isBlurry);
}
Future<void> dispose() async {
_interpreter?.close();
_interpreter = null;
}
static img.Image _letterboxWhite(img.Image src, int size) {
final scale = size / (src.width > src.height ? src.width : src.height);
final nw = (src.width * scale).round().clamp(1, size);
final nh = (src.height * scale).round().clamp(1, size);
final resized = img.copyResize(
src,
width: nw,
height: nh,
interpolation: img.Interpolation.linear,
);
final canvas = img.Image(width: size, height: size);
img.fill(canvas, color: img.ColorRgb8(255, 255, 255));
final padX = ((size - nw) / 2).floor();
final padY = ((size - nh) / 2).floor();
img.compositeImage(canvas, resized, dstX: padX, dstY: padY);
return canvas;
}
static Float32List _toNchw(img.Image canvas) {
final out = Float32List(1 * 3 * inputSize * inputSize);
const plane = inputSize * inputSize;
for (var y = 0; y < inputSize; y++) {
for (var x = 0; x < inputSize; x++) {
final p = canvas.getPixel(x, y);
final i = y * inputSize + x;
out[i] = p.r / 255.0;
out[plane + i] = p.g / 255.0;
out[plane * 2 + i] = p.b / 255.0;
}
}
return out;
}
}