feat: enhance signature img processing performance
This commit is contained in:
parent
feaf7aee9f
commit
2043bfc14c
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
|
|
|
|||
Loading…
Reference in New Issue