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 '../../domain/models/model.dart';
|
||||||
import '../../data/services/signature_image_processing_service.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
|
/// CachedSignatureCard extends SignatureCard with an internal processed cache
|
||||||
class CachedSignatureCard extends SignatureCard {
|
class CachedSignatureCard extends SignatureCard {
|
||||||
Uint8List? _cachedProcessed;
|
Uint8List? _cachedProcessed;
|
||||||
|
|
@ -129,6 +135,31 @@ class SignatureCardStateNotifier
|
||||||
return _processingService.processImage(asset.bytes, adjust);
|
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.
|
/// Clears all cached processed images.
|
||||||
void clearProcessedCache() {
|
void clearProcessedCache() {
|
||||||
for (final c in state) {
|
for (final c in state) {
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,42 @@
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
import 'package:image/image.dart' as img;
|
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;
|
import '../../domain/models/model.dart' as domain;
|
||||||
|
|
||||||
/// Service for processing signature images with graphic adjustments
|
/// Service for processing signature images with graphic adjustments
|
||||||
class SignatureImageProcessingService {
|
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.
|
/// Decode image bytes once and reuse the decoded image for preview processing.
|
||||||
img.Image? decode(Uint8List bytes) {
|
img.Image? decode(Uint8List bytes) {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,14 @@ class SignatureViewModel {
|
||||||
return notifier.getProcessedBytes(asset, adjust);
|
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() {
|
void clearCache() {
|
||||||
final notifier = ref.read(repo.signatureCardRepositoryProvider.notifier);
|
final notifier = ref.read(repo.signatureCardRepositoryProvider.notifier);
|
||||||
notifier.clearProcessedCache();
|
notifier.clearProcessedCache();
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
|
import 'dart:async';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:image/image.dart' as img;
|
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 'package:pdf_signature/l10n/app_localizations.dart';
|
||||||
import '../../pdf/widgets/adjustments_panel.dart';
|
import '../../pdf/widgets/adjustments_panel.dart';
|
||||||
import '../../../../domain/models/model.dart' as domain;
|
import '../../../../domain/models/model.dart' as domain;
|
||||||
|
|
@ -33,12 +36,21 @@ class ImageEditorDialog extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ImageEditorDialogState extends State<ImageEditorDialog> {
|
class _ImageEditorDialogState extends State<ImageEditorDialog> {
|
||||||
|
// UI state
|
||||||
late bool _aspectLocked;
|
late bool _aspectLocked;
|
||||||
late bool _bgRemoval;
|
late bool _bgRemoval;
|
||||||
late double _contrast;
|
late double _contrast;
|
||||||
late double _brightness;
|
late double _brightness;
|
||||||
late double _rotation;
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
|
@ -48,63 +60,120 @@ class _ImageEditorDialogState extends State<ImageEditorDialog> {
|
||||||
_contrast = widget.initialGraphicAdjust.contrast;
|
_contrast = widget.initialGraphicAdjust.contrast;
|
||||||
_brightness = widget.initialGraphicAdjust.brightness;
|
_brightness = widget.initialGraphicAdjust.brightness;
|
||||||
_rotation = widget.initialRotation;
|
_rotation = widget.initialRotation;
|
||||||
_processedBytes = widget.asset.bytes; // Initialize with original bytes
|
_originalBytes = widget.asset.bytes;
|
||||||
_updateProcessedBytes(); // Apply initial adjustments to preview
|
// 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;
|
|
||||||
|
|
||||||
// Apply contrast and brightness first
|
|
||||||
if (_contrast != 1.0 || _brightness != 1.0) {
|
|
||||||
processed = img.adjustColor(
|
|
||||||
processed,
|
|
||||||
contrast: _contrast,
|
|
||||||
brightness: _brightness,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply background removal after color adjustments
|
|
||||||
if (_bgRemoval) {
|
|
||||||
processed = _removeBackground(processed);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Encode back to PNG to preserve transparency
|
|
||||||
_processedBytes = Uint8List.fromList(img.encodePng(processed));
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// If processing fails, keep original bytes
|
|
||||||
_processedBytes = widget.asset.bytes;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove near-white background using simple threshold approach for maximum speed
|
Uint8List get _displayBytes =>
|
||||||
/// TODO: remove double loops with SIMD matrix operations for better performance
|
_bgRemoval
|
||||||
img.Image _removeBackground(img.Image image) {
|
? (_processedBgRemovedBytes ?? _originalBytes)
|
||||||
final result =
|
: _originalBytes;
|
||||||
image.hasAlpha ? img.Image.from(image) : image.convert(numChannels: 4);
|
|
||||||
|
|
||||||
// Simple and fast: single pass through all pixels
|
void _onBgRemovalChanged(bool value) {
|
||||||
for (int y = 0; y < result.height; y++) {
|
setState(() {
|
||||||
for (int x = 0; x < result.width; x++) {
|
_bgRemoval = value;
|
||||||
final pixel = result.getPixel(x, y);
|
if (value) {
|
||||||
final r = pixel.r;
|
_scheduleBgRemovalReprocess(immediate: true);
|
||||||
final g = pixel.g;
|
}
|
||||||
final b = pixel.b;
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Simple threshold: if pixel is close to white, make it transparent
|
void _scheduleBgRemovalReprocess({bool immediate = false}) {
|
||||||
const int threshold = 240; // Very close to white
|
if (!_bgRemoval) return; // Only when enabled
|
||||||
|
_bgRemovalDebounce?.cancel();
|
||||||
|
if (immediate) {
|
||||||
|
_recomputeBgRemoval();
|
||||||
|
} else {
|
||||||
|
_bgRemovalDebounce = Timer(
|
||||||
|
const Duration(milliseconds: 120),
|
||||||
|
_recomputeBgRemoval,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Then remove background on adjusted pixels
|
||||||
|
const int threshold = 240;
|
||||||
|
if (!working.hasAlpha) {
|
||||||
|
working = working.convert(numChannels: 4);
|
||||||
|
}
|
||||||
|
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) {
|
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
|
@override
|
||||||
|
|
@ -126,7 +195,8 @@ class _ImageEditorDialogState extends State<ImageEditorDialog> {
|
||||||
style: Theme.of(context).textTheme.titleMedium,
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
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(
|
SizedBox(
|
||||||
height: 160,
|
height: 160,
|
||||||
child: DecoratedBox(
|
child: DecoratedBox(
|
||||||
|
|
@ -136,10 +206,19 @@ class _ImageEditorDialogState extends State<ImageEditorDialog> {
|
||||||
),
|
),
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
child: RotatedSignatureImage(
|
child:
|
||||||
bytes: _processedBytes,
|
_bgRemoval
|
||||||
rotationDeg: _rotation,
|
? 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,
|
brightness: _brightness,
|
||||||
onAspectLockedChanged:
|
onAspectLockedChanged:
|
||||||
(v) => setState(() => _aspectLocked = v),
|
(v) => setState(() => _aspectLocked = v),
|
||||||
onBgRemovalChanged:
|
onBgRemovalChanged: (v) => _onBgRemovalChanged(v),
|
||||||
(v) => setState(() {
|
|
||||||
_bgRemoval = v;
|
|
||||||
_updateProcessedBytes();
|
|
||||||
}),
|
|
||||||
onContrastChanged:
|
onContrastChanged:
|
||||||
(v) => setState(() {
|
(v) => setState(() {
|
||||||
_contrast = v;
|
_contrast = v;
|
||||||
_updateProcessedBytes();
|
if (_bgRemoval) _scheduleBgRemovalReprocess();
|
||||||
}),
|
}),
|
||||||
onBrightnessChanged:
|
onBrightnessChanged:
|
||||||
(v) => setState(() {
|
(v) => setState(() {
|
||||||
_brightness = v;
|
_brightness = v;
|
||||||
_updateProcessedBytes();
|
if (_bgRemoval) _scheduleBgRemovalReprocess();
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
|
|
|
||||||
|
|
@ -29,15 +29,22 @@ class SignatureCard extends ConsumerWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final processedBytes = ref
|
final displayData = ref
|
||||||
.watch(signatureViewModelProvider)
|
.watch(signatureViewModelProvider)
|
||||||
.getProcessedBytes(asset, graphicAdjust);
|
.getDisplaySignatureData(asset, graphicAdjust);
|
||||||
// Fit inside 96x64 with 6px padding using the shared rotated image widget
|
// Fit inside 96x64 with 6px padding using the shared rotated image widget
|
||||||
const boxW = 96.0, boxH = 64.0, pad = 6.0;
|
const boxW = 96.0, boxH = 64.0, pad = 6.0;
|
||||||
Widget img = RotatedSignatureImage(
|
Widget coreImage = RotatedSignatureImage(
|
||||||
bytes: processedBytes,
|
bytes: displayData.bytes,
|
||||||
rotationDeg: rotationDeg,
|
rotationDeg: rotationDeg,
|
||||||
);
|
);
|
||||||
|
Widget img =
|
||||||
|
(displayData.colorMatrix != null)
|
||||||
|
? ColorFiltered(
|
||||||
|
colorFilter: ColorFilter.matrix(displayData.colorMatrix!),
|
||||||
|
child: coreImage,
|
||||||
|
)
|
||||||
|
: coreImage;
|
||||||
Widget base = SizedBox(
|
Widget base = SizedBox(
|
||||||
width: 96,
|
width: 96,
|
||||||
height: 64,
|
height: 64,
|
||||||
|
|
@ -170,10 +177,21 @@ class SignatureCard extends ConsumerWidget {
|
||||||
),
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(6.0),
|
padding: const EdgeInsets.all(6.0),
|
||||||
child: RotatedSignatureImage(
|
child:
|
||||||
bytes: processedBytes,
|
(displayData.colorMatrix != null)
|
||||||
rotationDeg: rotationDeg,
|
? ColorFiltered(
|
||||||
),
|
colorFilter: ColorFilter.matrix(
|
||||||
|
displayData.colorMatrix!,
|
||||||
|
),
|
||||||
|
child: RotatedSignatureImage(
|
||||||
|
bytes: displayData.bytes,
|
||||||
|
rotationDeg: rotationDeg,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: RotatedSignatureImage(
|
||||||
|
bytes: displayData.bytes,
|
||||||
|
rotationDeg: rotationDeg,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue