feat: enhance signature img processing performance

This commit is contained in:
insleker 2025-09-18 00:14:56 +08:00
parent feaf7aee9f
commit 2043bfc14c
5 changed files with 233 additions and 68 deletions

View File

@ -3,6 +3,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../domain/models/model.dart';
import '../../data/services/signature_image_processing_service.dart';
class DisplaySignatureData {
final Uint8List bytes; // bytes to render
final List<double>? colorMatrix; // optional GPU color matrix
const DisplaySignatureData({required this.bytes, this.colorMatrix});
}
/// CachedSignatureCard extends SignatureCard with an internal processed cache
class CachedSignatureCard extends SignatureCard {
Uint8List? _cachedProcessed;
@ -129,6 +135,31 @@ class SignatureCardStateNotifier
return _processingService.processImage(asset.bytes, adjust);
}
/// Provide display data optimized: if bgRemoval false, returns original bytes + matrix;
/// if bgRemoval true, returns processed bytes with baked adjustments and null matrix.
DisplaySignatureData getDisplayData(
SignatureAsset asset,
GraphicAdjust adjust,
) {
if (!adjust.bgRemoval) {
// Find card for potential original bytes (identical object) - no CPU processing.
for (final c in state) {
if (c.asset == asset) {
final matrix = _processingService.buildColorMatrix(adjust);
return DisplaySignatureData(
bytes: c.asset.bytes,
colorMatrix: matrix,
);
}
}
final matrix = _processingService.buildColorMatrix(adjust);
return DisplaySignatureData(bytes: asset.bytes, colorMatrix: matrix);
}
// bgRemoval path: need CPU processed bytes (includes brightness/contrast first)
final processed = getProcessedBytes(asset, adjust);
return DisplaySignatureData(bytes: processed, colorMatrix: null);
}
/// Clears all cached processed images.
void clearProcessedCache() {
for (final c in state) {

View File

@ -1,9 +1,42 @@
import 'dart:typed_data';
import 'package:image/image.dart' as img;
import 'package:colorfilter_generator/colorfilter_generator.dart';
import 'package:colorfilter_generator/addons.dart';
import '../../domain/models/model.dart' as domain;
/// Service for processing signature images with graphic adjustments
class SignatureImageProcessingService {
/// Build a GPU color matrix (brightness/contrast) using colorfilter_generator.
/// Domain neutral value is 1.0; addon neutral is 0. Map by (value-1.0).
List<double>? buildColorMatrix(domain.GraphicAdjust adjust) {
final bAddon = adjust.brightness - 1.0;
final cAddon = adjust.contrast - 1.0;
if (bAddon == 0 && cAddon == 0) return null; // identity
final gen = ColorFilterGenerator(
name: 'signature_adjust',
filters: [
if (bAddon != 0) ColorFilterAddons.brightness(bAddon),
if (cAddon != 0) ColorFilterAddons.contrast(cAddon),
],
);
return gen.matrix;
}
/// For display: if bgRemoval not requested, return original bytes + matrix.
/// If bgRemoval requested, perform full CPU pipeline (brightness/contrast then bg removal)
/// and return processed bytes with null matrix (already baked in).
Uint8List processForDisplay(Uint8List bytes, domain.GraphicAdjust adjust) {
if (!adjust.bgRemoval) {
// No CPU processing unless any color adjust combined with bg removal.
if (adjust.contrast == 1.0 && adjust.brightness == 1.0) {
return bytes; // identity
}
// We let GPU handle; return original bytes.
return bytes;
}
return processImage(bytes, adjust);
}
/// Decode image bytes once and reuse the decoded image for preview processing.
img.Image? decode(Uint8List bytes) {
try {

View File

@ -17,6 +17,14 @@ class SignatureViewModel {
return notifier.getProcessedBytes(asset, adjust);
}
repo.DisplaySignatureData getDisplaySignatureData(
domain.SignatureAsset asset,
domain.GraphicAdjust adjust,
) {
final notifier = ref.read(repo.signatureCardRepositoryProvider.notifier);
return notifier.getDisplayData(asset, adjust);
}
void clearCache() {
final notifier = ref.read(repo.signatureCardRepositoryProvider.notifier);
notifier.clearProcessedCache();

View File

@ -1,6 +1,9 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:image/image.dart' as img;
import 'package:colorfilter_generator/colorfilter_generator.dart';
import 'package:colorfilter_generator/addons.dart';
import 'package:pdf_signature/l10n/app_localizations.dart';
import '../../pdf/widgets/adjustments_panel.dart';
import '../../../../domain/models/model.dart' as domain;
@ -33,12 +36,21 @@ class ImageEditorDialog extends StatefulWidget {
}
class _ImageEditorDialogState extends State<ImageEditorDialog> {
// UI state
late bool _aspectLocked;
late bool _bgRemoval;
late double _contrast;
late double _brightness;
late double _rotation;
late Uint8List _processedBytes;
// Cached image data
late Uint8List _originalBytes; // Original asset bytes (never mutated)
Uint8List?
_processedBgRemovedBytes; // Cached brightness/contrast adjusted then bg-removed bytes
img.Image? _decodedBase; // Decoded original for processing
// Debounce for background removal (in case we later tie it to brightness/contrast)
Timer? _bgRemovalDebounce;
@override
void initState() {
@ -48,63 +60,120 @@ class _ImageEditorDialogState extends State<ImageEditorDialog> {
_contrast = widget.initialGraphicAdjust.contrast;
_brightness = widget.initialGraphicAdjust.brightness;
_rotation = widget.initialRotation;
_processedBytes = widget.asset.bytes; // Initialize with original bytes
_updateProcessedBytes(); // Apply initial adjustments to preview
_originalBytes = widget.asset.bytes;
// Decode lazily only if/when background removal is needed
if (_bgRemoval) {
_scheduleBgRemovalReprocess(immediate: true);
}
}
/// Update processed image bytes when processing parameters change
void _updateProcessedBytes() {
try {
final decoded = img.decodeImage(widget.asset.bytes);
if (decoded != null) {
img.Image processed = decoded;
Uint8List get _displayBytes =>
_bgRemoval
? (_processedBgRemovedBytes ?? _originalBytes)
: _originalBytes;
// Apply contrast and brightness first
if (_contrast != 1.0 || _brightness != 1.0) {
processed = img.adjustColor(
processed,
contrast: _contrast,
brightness: _brightness,
void _onBgRemovalChanged(bool value) {
setState(() {
_bgRemoval = value;
if (value) {
_scheduleBgRemovalReprocess(immediate: true);
}
});
}
void _scheduleBgRemovalReprocess({bool immediate = false}) {
if (!_bgRemoval) return; // Only when enabled
_bgRemovalDebounce?.cancel();
if (immediate) {
_recomputeBgRemoval();
} else {
_bgRemovalDebounce = Timer(
const Duration(milliseconds: 120),
_recomputeBgRemoval,
);
}
// Apply background removal after color adjustments
if (_bgRemoval) {
processed = _removeBackground(processed);
}
// Encode back to PNG to preserve transparency
_processedBytes = Uint8List.fromList(img.encodePng(processed));
void _recomputeBgRemoval() {
_decodedBase ??= img.decodeImage(_originalBytes);
final base = _decodedBase;
if (base == null) return;
// Apply brightness & contrast first (domain uses 1.0 neutral)
img.Image working = img.Image.from(base);
final needAdjust = _brightness != 1.0 || _contrast != 1.0;
if (needAdjust) {
working = img.adjustColor(
working,
brightness: _brightness,
contrast: _contrast,
);
}
} catch (e) {
// If processing fails, keep original bytes
_processedBytes = widget.asset.bytes;
// Then remove background on adjusted pixels
const int threshold = 240;
if (!working.hasAlpha) {
working = working.convert(numChannels: 4);
}
}
/// Remove near-white background using simple threshold approach for maximum speed
/// TODO: remove double loops with SIMD matrix operations for better performance
img.Image _removeBackground(img.Image image) {
final result =
image.hasAlpha ? img.Image.from(image) : image.convert(numChannels: 4);
// Simple and fast: single pass through all pixels
for (int y = 0; y < result.height; y++) {
for (int x = 0; x < result.width; x++) {
final pixel = result.getPixel(x, y);
final r = pixel.r;
final g = pixel.g;
final b = pixel.b;
// Simple threshold: if pixel is close to white, make it transparent
const int threshold = 240; // Very close to white
for (int y = 0; y < working.height; y++) {
for (int x = 0; x < working.width; x++) {
final p = working.getPixel(x, y);
final r = p.r, g = p.g, b = p.b;
if (r >= threshold && g >= threshold && b >= threshold) {
result.setPixelRgba(x, y, r, g, b, 0);
working.setPixelRgba(x, y, r, g, b, 0);
}
}
}
final bytes = Uint8List.fromList(img.encodePng(working));
if (!mounted) return;
setState(() => _processedBgRemovedBytes = bytes);
}
return result;
ColorFilter _currentColorFilter() {
// The original domain model uses 1.0 as neutral for brightness/contrast.
// colorfilter_generator expects values between -1..1 for adjustments when using addons.
// We'll map: domain brightness (default 1.0) -> addon brightness(value-1)
// Same for contrast.
final bAddon = _brightness - 1.0; // so 1.0 => 0
final cAddon = _contrast - 1.0; // so 1.0 => 0
final generator = ColorFilterGenerator(
name: 'dynamic_adjust',
filters: [
if (bAddon != 0) ColorFilterAddons.brightness(bAddon),
if (cAddon != 0) ColorFilterAddons.contrast(cAddon),
],
);
// If neutral, return identity filter to avoid unnecessary matrix mul
if (bAddon == 0 && cAddon == 0) {
// Identity matrix
return const ColorFilter.matrix(<double>[
1,
0,
0,
0,
0,
0,
1,
0,
0,
0,
0,
0,
1,
0,
0,
0,
0,
0,
1,
0,
]);
}
return ColorFilter.matrix(generator.matrix);
}
@override
void dispose() {
_bgRemovalDebounce?.cancel();
super.dispose();
}
@override
@ -126,7 +195,8 @@ class _ImageEditorDialogState extends State<ImageEditorDialog> {
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 12),
// Preview with actual signature image
// Preview: if bg removal active we already applied adjustments in CPU pipeline,
// otherwise apply brightness/contrast via GPU ColorFilter.
SizedBox(
height: 160,
child: DecoratedBox(
@ -136,9 +206,18 @@ class _ImageEditorDialogState extends State<ImageEditorDialog> {
),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: RotatedSignatureImage(
bytes: _processedBytes,
child:
_bgRemoval
? RotatedSignatureImage(
bytes: _displayBytes,
rotationDeg: _rotation,
)
: ColorFiltered(
colorFilter: _currentColorFilter(),
child: RotatedSignatureImage(
bytes: _displayBytes,
rotationDeg: _rotation,
),
),
),
),
@ -152,20 +231,16 @@ class _ImageEditorDialogState extends State<ImageEditorDialog> {
brightness: _brightness,
onAspectLockedChanged:
(v) => setState(() => _aspectLocked = v),
onBgRemovalChanged:
(v) => setState(() {
_bgRemoval = v;
_updateProcessedBytes();
}),
onBgRemovalChanged: (v) => _onBgRemovalChanged(v),
onContrastChanged:
(v) => setState(() {
_contrast = v;
_updateProcessedBytes();
if (_bgRemoval) _scheduleBgRemovalReprocess();
}),
onBrightnessChanged:
(v) => setState(() {
_brightness = v;
_updateProcessedBytes();
if (_bgRemoval) _scheduleBgRemovalReprocess();
}),
),
const SizedBox(height: 8),

View File

@ -29,15 +29,22 @@ class SignatureCard extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final processedBytes = ref
final displayData = ref
.watch(signatureViewModelProvider)
.getProcessedBytes(asset, graphicAdjust);
.getDisplaySignatureData(asset, graphicAdjust);
// Fit inside 96x64 with 6px padding using the shared rotated image widget
const boxW = 96.0, boxH = 64.0, pad = 6.0;
Widget img = RotatedSignatureImage(
bytes: processedBytes,
Widget coreImage = RotatedSignatureImage(
bytes: displayData.bytes,
rotationDeg: rotationDeg,
);
Widget img =
(displayData.colorMatrix != null)
? ColorFiltered(
colorFilter: ColorFilter.matrix(displayData.colorMatrix!),
child: coreImage,
)
: coreImage;
Widget base = SizedBox(
width: 96,
height: 64,
@ -170,8 +177,19 @@ class SignatureCard extends ConsumerWidget {
),
child: Padding(
padding: const EdgeInsets.all(6.0),
child:
(displayData.colorMatrix != null)
? ColorFiltered(
colorFilter: ColorFilter.matrix(
displayData.colorMatrix!,
),
child: RotatedSignatureImage(
bytes: processedBytes,
bytes: displayData.bytes,
rotationDeg: rotationDeg,
),
)
: RotatedSignatureImage(
bytes: displayData.bytes,
rotationDeg: rotationDeg,
),
),