You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
cloudsolutions-atoms/lib/helper/image_quality_helper.dart

167 lines
5.4 KiB
Dart

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