Files
meshcore-sar/lib/services/image_codec_service.dart
T

148 lines
5.3 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:async';
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
import 'package:flutter_avif/flutter_avif.dart';
/// Compresses and resizes an image for low-bandwidth mesh transmission.
///
/// Target: ≤256×256 pixels, grayscale AVIF at aggressive quality.
/// A typical 256×256 grayscale AVIF at quality 90 is highly compressed.
/// → 720 fragments at 152 bytes each.
class ImageCodecService {
static const int _normalAvifSpeed = 6;
static const int _ultraAvifSpeed = 4;
/// Compress [rawBytes] (any decodable format: JPEG/PNG/WebP/AVIF) to a
/// small grayscale AVIF suitable for mesh transmission.
///
/// [maxDimension] caps width and height (default 256); aspect ratio is
/// preserved and images smaller than the cap are not upscaled.
/// [compression] 0 = lossless, 100 = smallest/worst (libavif CQ scale).
///
/// Returns `(bytes, width, height)` or null if decoding or encoding fails.
static Future<({Uint8List bytes, int width, int height})?> compress(
Uint8List rawBytes, {
int maxDimension = 256,
int compression = 90,
bool grayscale = true,
bool ultraMode = false,
}) async {
try {
final effectiveMaxDimension = maxDimension.clamp(32, 1024);
final effectiveCompression = ultraMode
? (compression + 12).clamp(10, 100)
: compression.clamp(10, 100);
final forceGrayscale = ultraMode ? true : grayscale;
// 1a. Probe original dimensions (no resize).
final probeCodec = await ui.instantiateImageCodec(rawBytes);
final probeFrame = await probeCodec.getNextFrame();
final srcW = probeFrame.image.width;
final srcH = probeFrame.image.height;
probeFrame.image.dispose();
// 1b. Compute contain dimensions: scale down only the limiting axis so
// the image fits within maxDimension×maxDimension without stretching.
int dstW = srcW;
int dstH = srcH;
if (srcW > effectiveMaxDimension || srcH > effectiveMaxDimension) {
if (srcW >= srcH) {
dstW = effectiveMaxDimension;
dstH = (srcH * effectiveMaxDimension / srcW).round().clamp(
1,
effectiveMaxDimension,
);
} else {
dstH = effectiveMaxDimension;
dstW = (srcW * effectiveMaxDimension / srcH).round().clamp(
1,
effectiveMaxDimension,
);
}
}
// 1c. Decode at the exact contain size (single axis constrained).
final codec = await ui.instantiateImageCodec(
rawBytes,
targetWidth: dstW,
targetHeight: dstH,
allowUpscaling: false,
);
final frame = await codec.getNextFrame();
final image = frame.image;
final w = image.width;
final h = image.height;
// 2. Export RGBA pixels.
final byteData = await image.toByteData(
format: ui.ImageByteFormat.rawRgba,
);
image.dispose();
if (byteData == null) return null;
// 3. Optionally convert to grayscale in-place (luminance).
final rgba = byteData.buffer.asUint8List();
if (forceGrayscale) {
for (var i = 0; i < rgba.length; i += 4) {
final lum =
(0.299 * rgba[i] + 0.587 * rgba[i + 1] + 0.114 * rgba[i + 2])
.round()
.clamp(0, 255);
rgba[i] = lum;
rgba[i + 1] = lum;
rgba[i + 2] = lum;
rgba[i + 3] = 255;
}
}
// 4. Re-encode grayscale RGBA → PNG so encodeAvif can decode it.
// encodeAvif() takes an encoded image (PNG/JPEG), not raw RGBA.
final buffer = await ui.ImmutableBuffer.fromUint8List(rgba);
final descriptor = ui.ImageDescriptor.raw(
buffer,
width: w,
height: h,
pixelFormat: ui.PixelFormat.rgba8888,
);
final greyCodec = await descriptor.instantiateCodec();
final greyFrame = await greyCodec.getNextFrame();
final greyImage = greyFrame.image;
final pngData = await greyImage.toByteData(
format: ui.ImageByteFormat.png,
);
greyImage.dispose();
if (pngData == null) return null;
final pngBytes = pngData.buffer.asUint8List();
// 5. Encode PNG → AVIF.
// maxQuantizer/minQuantizer: libavif CQ scale (0 = lossless, 63 = worst).
// compression=90 maps to maxQuantizer≈57, minQuantizer≈37.
final maxQ = ((effectiveCompression / 100) * 63).round().clamp(0, 63);
final minQ = (maxQ * 0.65).round().clamp(0, maxQ);
final avif = await encodeAvif(
pngBytes,
maxThreads: 2,
maxQuantizer: maxQ,
minQuantizer: minQ,
// We force fully opaque alpha, so alpha can be quantized aggressively.
maxQuantizerAlpha: 63,
minQuantizerAlpha: 63,
// Slower speed improves compression efficiency at similar quality.
speed: ultraMode ? _ultraAvifSpeed : _normalAvifSpeed,
keepExif: false,
);
if (avif.isEmpty) return null;
debugPrint(
'📷 [ImageCodec] ${rawBytes.length}B → $w×$h grayscale AVIF '
'${avif.length}B (${(avif.length * 100 / rawBytes.length).round()}%)',
);
return (bytes: avif, width: w, height: h);
} catch (e, st) {
debugPrint('❌ [ImageCodec] compress error: $e\n$st');
return null;
}
}
}