bug 2319, 2284 fixed.
parent
22cf75fce7
commit
166f43979a
@ -0,0 +1,166 @@
|
||||
import 'dart:developer';
|
||||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:image_blur_detection/image_blur_detection.dart';
|
||||
|
||||
/// Longest edge (in pixels) used for quality analysis.
|
||||
///
|
||||
/// The picked image is downscaled to this size *before* any pixel analysis.
|
||||
/// This is the single most important guard against OOM crashes on low-end
|
||||
/// devices: a 12MP photo decodes to ~48 MB of RGBA, and the Laplacian pass in
|
||||
/// `image_blur_detection` then allocates a growable `List<double>` of one entry
|
||||
/// per pixel (~96 MB, with transient spikes to ~190 MB while the list grows).
|
||||
///
|
||||
/// At 1024px the same pipeline peaks at roughly 10 MB.
|
||||
const int _kMaxAnalysisDimension = 1024;
|
||||
|
||||
/// Result of an image quality check.
|
||||
///
|
||||
/// [analysisFailed] is `true` when the image could not be analysed at all
|
||||
/// (unsupported format, decode failure, out of memory, ...). Callers should
|
||||
/// treat that as "allow the image through" rather than blocking the user.
|
||||
@immutable
|
||||
class ImageQualityCheckResult {
|
||||
final bool isValid;
|
||||
final List<String> issues;
|
||||
final bool analysisFailed;
|
||||
|
||||
const ImageQualityCheckResult({
|
||||
required this.isValid,
|
||||
this.issues = const <String>[],
|
||||
this.analysisFailed = false,
|
||||
});
|
||||
|
||||
/// Used when analysis is impossible — fails open so the user is never stuck.
|
||||
static const ImageQualityCheckResult skipped = ImageQualityCheckResult(
|
||||
isValid: true,
|
||||
analysisFailed: true,
|
||||
);
|
||||
}
|
||||
|
||||
/// Payload sent to the background isolate.
|
||||
@immutable
|
||||
class _AnalysisRequest {
|
||||
final Uint8List pngBytes;
|
||||
final double blurThreshold;
|
||||
final double minBrightness;
|
||||
final double maxBrightness;
|
||||
final double minContrast;
|
||||
|
||||
const _AnalysisRequest({
|
||||
required this.pngBytes,
|
||||
required this.blurThreshold,
|
||||
required this.minBrightness,
|
||||
required this.maxBrightness,
|
||||
required this.minContrast,
|
||||
});
|
||||
}
|
||||
|
||||
/// Analyses [file] for blur / brightness / contrast problems.
|
||||
///
|
||||
/// Safe to call on large photos: the image is downscaled using the platform's
|
||||
/// native decoder, and the (pure Dart, CPU-heavy) analysis runs in a background
|
||||
/// isolate so the UI thread never blocks.
|
||||
///
|
||||
/// Never throws — on any failure it returns [ImageQualityCheckResult.skipped].
|
||||
Future<ImageQualityCheckResult> analyzeImageQuality(
|
||||
File file, {
|
||||
QualityConfig config = const QualityConfig(),
|
||||
}) async {
|
||||
try {
|
||||
final Uint8List originalBytes = await file.readAsBytes();
|
||||
|
||||
// Downscale natively first. This keeps peak memory low and is far faster
|
||||
// than decoding at full resolution in Dart.
|
||||
final Uint8List? preview = await _downscaleToPng(originalBytes);
|
||||
if (preview == null) return ImageQualityCheckResult.skipped;
|
||||
|
||||
return await compute(
|
||||
_analyzeInIsolate,
|
||||
_AnalysisRequest(
|
||||
pngBytes: preview,
|
||||
blurThreshold: config.blurThreshold,
|
||||
minBrightness: config.minBrightness,
|
||||
maxBrightness: config.maxBrightness,
|
||||
minContrast: config.minContrast,
|
||||
),
|
||||
);
|
||||
} catch (e, s) {
|
||||
log('Image quality analysis failed', name: 'ImageQuality', error: e, stackTrace: s);
|
||||
return ImageQualityCheckResult.skipped;
|
||||
}
|
||||
}
|
||||
|
||||
/// Decodes [bytes] at a reduced resolution using the engine's native codec and
|
||||
/// re-encodes the small result as PNG.
|
||||
///
|
||||
/// PNG is used because it is lossless (so brightness/contrast statistics are
|
||||
/// preserved exactly) and because `ui.Image.toByteData` can produce it without
|
||||
/// pulling in a Dart encoder.
|
||||
Future<Uint8List?> _downscaleToPng(Uint8List bytes) async {
|
||||
ui.ImmutableBuffer? buffer;
|
||||
ui.ImageDescriptor? descriptor;
|
||||
ui.Codec? codec;
|
||||
ui.Image? image;
|
||||
|
||||
try {
|
||||
buffer = await ui.ImmutableBuffer.fromUint8List(bytes);
|
||||
descriptor = await ui.ImageDescriptor.encoded(buffer);
|
||||
|
||||
final int width = descriptor.width;
|
||||
final int height = descriptor.height;
|
||||
if (width <= 0 || height <= 0) return null;
|
||||
|
||||
final int longestEdge = width >= height ? width : height;
|
||||
// Only ever scale down, never up.
|
||||
final double scale = longestEdge > _kMaxAnalysisDimension ? _kMaxAnalysisDimension / longestEdge : 1.0;
|
||||
|
||||
final int targetWidth = (width * scale).round().clamp(1, width);
|
||||
final int targetHeight = (height * scale).round().clamp(1, height);
|
||||
|
||||
codec = await descriptor.instantiateCodec(
|
||||
targetWidth: targetWidth,
|
||||
targetHeight: targetHeight,
|
||||
);
|
||||
|
||||
final ui.FrameInfo frame = await codec.getNextFrame();
|
||||
image = frame.image;
|
||||
|
||||
final ByteData? png = await image.toByteData(format: ui.ImageByteFormat.png);
|
||||
return png?.buffer.asUint8List();
|
||||
} catch (e, s) {
|
||||
log('Downscale failed', name: 'ImageQuality', error: e, stackTrace: s);
|
||||
return null;
|
||||
} finally {
|
||||
image?.dispose();
|
||||
codec?.dispose();
|
||||
descriptor?.dispose();
|
||||
buffer?.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs in a background isolate. Must stay a top-level function.
|
||||
Future<ImageQualityCheckResult> _analyzeInIsolate(_AnalysisRequest request) async {
|
||||
try {
|
||||
final validator = ImageQualityValidator(
|
||||
config: QualityConfig(
|
||||
blurThreshold: request.blurThreshold,
|
||||
minBrightness: request.minBrightness,
|
||||
maxBrightness: request.maxBrightness,
|
||||
minContrast: request.minContrast,
|
||||
),
|
||||
);
|
||||
|
||||
final result = await validator.validate(request.pngBytes);
|
||||
return ImageQualityCheckResult(
|
||||
isValid: result.isValid,
|
||||
issues: List<String>.unmodifiable(result.issues),
|
||||
);
|
||||
} catch (_) {
|
||||
// Decode/analysis problems must not crash the isolate — fail open.
|
||||
return ImageQualityCheckResult.skipped;
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue