Improve image zoom quality

This commit is contained in:
Janez T
2026-03-01 19:17:09 +01:00
parent be7fe6c4b0
commit 0e7ba5572d
20 changed files with 2157 additions and 15 deletions

View File

@@ -0,0 +1,122 @@
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 {
/// 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,
}) async {
try {
// 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 > maxDimension || srcH > maxDimension) {
if (srcW >= srcH) {
dstW = maxDimension;
dstH = (srcH * maxDimension / srcW).round().clamp(1, maxDimension);
} else {
dstH = maxDimension;
dstW = (srcW * maxDimension / srcH).round().clamp(1, maxDimension);
}
}
// 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. Convert to grayscale in-place (luminance, keep alpha = 255).
final rgba = byteData.buffer.asUint8List();
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; // fully opaque
}
// 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 = ((compression / 100) * 63).round().clamp(0, 63);
final minQ = (maxQ * 0.65).round().clamp(0, maxQ);
final avif = await encodeAvif(
pngBytes,
maxQuantizer: maxQ,
minQuantizer: minQ,
speed: 8, // fast encode (0 = slowest/best, 10 = fastest)
);
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;
}
}
}