mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-12 00:40:28 +00:00
Improve image zoom quality
This commit is contained in:
122
lib/services/image_codec_service.dart
Normal file
122
lib/services/image_codec_service.dart
Normal 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.
|
||||
/// → 7–20 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
35
lib/services/image_preferences.dart
Normal file
35
lib/services/image_preferences.dart
Normal file
@@ -0,0 +1,35 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Stores user-selected image compression settings.
|
||||
class ImagePreferences {
|
||||
static const String _maxSizeKey = 'image_max_size';
|
||||
// Keep the legacy key name so existing users retain their saved value.
|
||||
static const String _qualityKey = 'image_quality';
|
||||
|
||||
static const int defaultMaxSize = 256;
|
||||
static const int defaultQuality = 90;
|
||||
|
||||
static const List<int> supportedSizes = [64, 128, 256];
|
||||
|
||||
static Future<int> getMaxSize() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final value = prefs.getInt(_maxSizeKey) ?? defaultMaxSize;
|
||||
return supportedSizes.contains(value) ? value : defaultMaxSize;
|
||||
}
|
||||
|
||||
static Future<void> setMaxSize(int size) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt(_maxSizeKey, size);
|
||||
}
|
||||
|
||||
static Future<int> getCompression() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final value = prefs.getInt(_qualityKey) ?? defaultQuality;
|
||||
return value.clamp(10, 90);
|
||||
}
|
||||
|
||||
static Future<void> setCompression(int compression) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt(_qualityKey, compression.clamp(10, 90));
|
||||
}
|
||||
}
|
||||
@@ -25,10 +25,14 @@ class VoiceRecorderService {
|
||||
///
|
||||
/// [chunkDuration] controls how often samples are emitted (default 1 s).
|
||||
/// [enableBandPassFilter] applies voice-tuned band-pass filtering when true.
|
||||
/// [enableCompressor] normalizes speech dynamics before encoding.
|
||||
/// [enableLimiter] protects against clipping peaks before encoding.
|
||||
/// The returned stream emits [Int16List] chunks that are ready for Codec2 encoding.
|
||||
Stream<Int16List> startCapture({
|
||||
Duration chunkDuration = const Duration(seconds: 1),
|
||||
bool enableBandPassFilter = true,
|
||||
bool enableCompressor = true,
|
||||
bool enableLimiter = true,
|
||||
}) {
|
||||
if (_isRecording) {
|
||||
throw StateError('VoiceRecorderService: already recording');
|
||||
@@ -42,6 +46,8 @@ class VoiceRecorderService {
|
||||
_startRecording(
|
||||
chunkDuration,
|
||||
enableBandPassFilter: enableBandPassFilter,
|
||||
enableCompressor: enableCompressor,
|
||||
enableLimiter: enableLimiter,
|
||||
);
|
||||
return _controller!.stream;
|
||||
}
|
||||
@@ -49,6 +55,8 @@ class VoiceRecorderService {
|
||||
Future<void> _startRecording(
|
||||
Duration chunkDuration, {
|
||||
required bool enableBandPassFilter,
|
||||
required bool enableCompressor,
|
||||
required bool enableLimiter,
|
||||
}) async {
|
||||
final config = const RecordConfig(
|
||||
encoder: AudioEncoder.pcm16bits,
|
||||
@@ -64,6 +72,11 @@ class VoiceRecorderService {
|
||||
lowCutHz: 250.0,
|
||||
highCutHz: 3400.0,
|
||||
);
|
||||
final dynamics = _VoiceDynamicsProcessor(
|
||||
sampleRate: 8000,
|
||||
enableCompressor: enableCompressor,
|
||||
enableLimiter: enableLimiter,
|
||||
);
|
||||
final chunkBytes = 8000 * 2 * chunkDuration.inMilliseconds ~/ 1000;
|
||||
final buffer = <int>[];
|
||||
|
||||
@@ -74,18 +87,20 @@ class VoiceRecorderService {
|
||||
final chunk = buffer.sublist(0, chunkBytes);
|
||||
buffer.removeRange(0, chunkBytes);
|
||||
final pcm = _bytesToInt16(Uint8List.fromList(chunk));
|
||||
_controller?.add(
|
||||
enableBandPassFilter ? voiceFilter.process(pcm) : pcm,
|
||||
);
|
||||
final filtered = enableBandPassFilter
|
||||
? voiceFilter.process(pcm)
|
||||
: pcm;
|
||||
_controller?.add(dynamics.process(filtered));
|
||||
}
|
||||
},
|
||||
onDone: () {
|
||||
if (buffer.isNotEmpty) {
|
||||
final padded = _padToEven(buffer);
|
||||
final pcm = _bytesToInt16(Uint8List.fromList(padded));
|
||||
_controller?.add(
|
||||
enableBandPassFilter ? voiceFilter.process(pcm) : pcm,
|
||||
);
|
||||
final filtered = enableBandPassFilter
|
||||
? voiceFilter.process(pcm)
|
||||
: pcm;
|
||||
_controller?.add(dynamics.process(filtered));
|
||||
}
|
||||
_controller?.close();
|
||||
},
|
||||
@@ -138,6 +153,107 @@ class VoiceRecorderService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Light speech-focused dynamics processing.
|
||||
///
|
||||
/// Compressor improves low-level intelligibility; limiter prevents peaks that
|
||||
/// can create harsh codec artifacts.
|
||||
class _VoiceDynamicsProcessor {
|
||||
final bool _enableCompressor;
|
||||
final bool _enableLimiter;
|
||||
final _SimpleCompressor _compressor;
|
||||
final _PeakLimiter _limiter;
|
||||
|
||||
_VoiceDynamicsProcessor({
|
||||
required int sampleRate,
|
||||
required bool enableCompressor,
|
||||
required bool enableLimiter,
|
||||
}) : _enableCompressor = enableCompressor,
|
||||
_enableLimiter = enableLimiter,
|
||||
_compressor = _SimpleCompressor(
|
||||
sampleRate: sampleRate.toDouble(),
|
||||
thresholdDb: -18.0,
|
||||
ratio: 2.5,
|
||||
attackMs: 8.0,
|
||||
releaseMs: 120.0,
|
||||
makeupGainDb: 4.0,
|
||||
),
|
||||
_limiter = _PeakLimiter(ceilingDb: -1.0);
|
||||
|
||||
Int16List process(Int16List input) {
|
||||
final output = Int16List(input.length);
|
||||
for (var i = 0; i < input.length; i++) {
|
||||
var sample = input[i].toDouble();
|
||||
if (_enableCompressor) {
|
||||
sample = _compressor.process(sample);
|
||||
}
|
||||
if (_enableLimiter) {
|
||||
sample = _limiter.process(sample);
|
||||
}
|
||||
output[i] = sample.clamp(-32768.0, 32767.0).round();
|
||||
}
|
||||
return output;
|
||||
}
|
||||
}
|
||||
|
||||
/// Basic feed-forward compressor with attack/release smoothing.
|
||||
class _SimpleCompressor {
|
||||
final double _thresholdDb;
|
||||
final double _ratio;
|
||||
final double _makeupGain;
|
||||
final double _attackCoeff;
|
||||
final double _releaseCoeff;
|
||||
static const double _eps = 1.0;
|
||||
|
||||
double _env = 0.0;
|
||||
double _gain = 1.0;
|
||||
|
||||
_SimpleCompressor({
|
||||
required double sampleRate,
|
||||
required double thresholdDb,
|
||||
required double ratio,
|
||||
required double attackMs,
|
||||
required double releaseMs,
|
||||
required double makeupGainDb,
|
||||
}) : _thresholdDb = thresholdDb,
|
||||
_ratio = ratio,
|
||||
_makeupGain = math.pow(10.0, makeupGainDb / 20.0).toDouble(),
|
||||
_attackCoeff = math.exp(-1.0 / (sampleRate * (attackMs / 1000.0))),
|
||||
_releaseCoeff = math.exp(-1.0 / (sampleRate * (releaseMs / 1000.0)));
|
||||
|
||||
double process(double x) {
|
||||
final absX = x.abs();
|
||||
final envCoeff = absX > _env ? _attackCoeff : _releaseCoeff;
|
||||
_env = envCoeff * _env + (1.0 - envCoeff) * absX;
|
||||
|
||||
final envDb = 20.0 * math.log((_env + _eps) / 32768.0) / math.ln10;
|
||||
var targetGain = 1.0;
|
||||
if (envDb > _thresholdDb) {
|
||||
final outDb = _thresholdDb + (envDb - _thresholdDb) / _ratio;
|
||||
final gainDb = outDb - envDb;
|
||||
targetGain = math.pow(10.0, gainDb / 20.0).toDouble();
|
||||
}
|
||||
targetGain *= _makeupGain;
|
||||
|
||||
final gainCoeff = targetGain < _gain ? _attackCoeff : _releaseCoeff;
|
||||
_gain = gainCoeff * _gain + (1.0 - gainCoeff) * targetGain;
|
||||
return x * _gain;
|
||||
}
|
||||
}
|
||||
|
||||
/// Hard peak limiter with fixed ceiling.
|
||||
class _PeakLimiter {
|
||||
final double _ceiling;
|
||||
|
||||
_PeakLimiter({required double ceilingDb})
|
||||
: _ceiling = 32767.0 * math.pow(10.0, ceilingDb / 20.0).toDouble();
|
||||
|
||||
double process(double x) {
|
||||
if (x > _ceiling) return _ceiling;
|
||||
if (x < -_ceiling) return -_ceiling;
|
||||
return x;
|
||||
}
|
||||
}
|
||||
|
||||
/// Band-pass filter tuned for human voice at 8 kHz input.
|
||||
///
|
||||
/// Uses a cascaded high-pass + low-pass biquad to attenuate very low-frequency
|
||||
|
||||
Reference in New Issue
Block a user