diff --git a/lib/data/repositories/signature_card_repository.dart b/lib/data/repositories/signature_card_repository.dart index 8a1a40d..675c195 100644 --- a/lib/data/repositories/signature_card_repository.dart +++ b/lib/data/repositories/signature_card_repository.dart @@ -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? 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) { diff --git a/lib/data/services/signature_image_processing_service.dart b/lib/data/services/signature_image_processing_service.dart index 357ee01..4720598 100644 --- a/lib/data/services/signature_image_processing_service.dart +++ b/lib/data/services/signature_image_processing_service.dart @@ -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? 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 { diff --git a/lib/ui/features/signature/view_model/signature_view_model.dart b/lib/ui/features/signature/view_model/signature_view_model.dart index 562fd80..e006cf2 100644 --- a/lib/ui/features/signature/view_model/signature_view_model.dart +++ b/lib/ui/features/signature/view_model/signature_view_model.dart @@ -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(); diff --git a/lib/ui/features/signature/widgets/image_editor_dialog.dart b/lib/ui/features/signature/widgets/image_editor_dialog.dart index ff87496..83969b4 100644 --- a/lib/ui/features/signature/widgets/image_editor_dialog.dart +++ b/lib/ui/features/signature/widgets/image_editor_dialog.dart @@ -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 { + // 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 { _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 - } - - /// 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; + _originalBytes = widget.asset.bytes; + // Decode lazily only if/when background removal is needed + if (_bgRemoval) { + _scheduleBgRemovalReprocess(immediate: true); } } - /// 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); + Uint8List get _displayBytes => + _bgRemoval + ? (_processedBgRemovedBytes ?? _originalBytes) + : _originalBytes; - // 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; + void _onBgRemovalChanged(bool value) { + setState(() { + _bgRemoval = value; + if (value) { + _scheduleBgRemovalReprocess(immediate: true); + } + }); + } - // Simple threshold: if pixel is close to white, make it transparent - const int threshold = 240; // Very close to white + void _scheduleBgRemovalReprocess({bool immediate = false}) { + 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) { - 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([ + 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 { 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,10 +206,19 @@ class _ImageEditorDialogState extends State { ), child: ClipRRect( borderRadius: BorderRadius.circular(8), - child: RotatedSignatureImage( - bytes: _processedBytes, - rotationDeg: _rotation, - ), + child: + _bgRemoval + ? RotatedSignatureImage( + bytes: _displayBytes, + rotationDeg: _rotation, + ) + : ColorFiltered( + colorFilter: _currentColorFilter(), + child: RotatedSignatureImage( + bytes: _displayBytes, + rotationDeg: _rotation, + ), + ), ), ), ), @@ -152,20 +231,16 @@ class _ImageEditorDialogState extends State { 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), diff --git a/lib/ui/features/signature/widgets/signature_card.dart b/lib/ui/features/signature/widgets/signature_card.dart index 4e337f2..9dda3f0 100644 --- a/lib/ui/features/signature/widgets/signature_card.dart +++ b/lib/ui/features/signature/widgets/signature_card.dart @@ -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,10 +177,21 @@ class SignatureCard extends ConsumerWidget { ), child: Padding( padding: const EdgeInsets.all(6.0), - child: RotatedSignatureImage( - bytes: processedBytes, - rotationDeg: rotationDeg, - ), + child: + (displayData.colorMatrix != null) + ? ColorFiltered( + colorFilter: ColorFilter.matrix( + displayData.colorMatrix!, + ), + child: RotatedSignatureImage( + bytes: displayData.bytes, + rotationDeg: rotationDeg, + ), + ) + : RotatedSignatureImage( + bytes: displayData.bytes, + rotationDeg: rotationDeg, + ), ), ), ),