From 3651b073bf94ca6f42b6c041d21e2959967d4775 Mon Sep 17 00:00:00 2001 From: gary Date: Sun, 22 Feb 2026 18:40:33 +0100 Subject: [PATCH] feat(cap-detect): show top 3 dominant colors + mixed average --- lib/main.dart | 155 +++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 135 insertions(+), 20 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 01ec33a..e10f811 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -435,6 +435,7 @@ class _MosaicHomePageState extends State detected = { 'dominantColor': fallbackColor, 'averageColor': fallbackColor, + 'topDominantColors': [fallbackColor], 'usedFallback': true, 'circleX': imageW / 2, 'circleY': imageH / 2, @@ -1290,6 +1291,7 @@ class _CapPhotoReviewPageState extends State<_CapPhotoReviewPage> { late Color _dominantColor; late Color _averageColor; + late List _topDominantColors; late Uint8List _previewBytes; late bool _usedFallback; late double _imageW; @@ -1310,6 +1312,10 @@ class _CapPhotoReviewPageState extends State<_CapPhotoReviewPage> { final detected = widget.detected; _dominantColor = Color(detected['dominantColor'] as int); _averageColor = Color(detected['averageColor'] as int); + _topDominantColors = ((detected['topDominantColors'] as List?) ?? + [detected['dominantColor']]) + .map((e) => Color(e as int)) + .toList(growable: false); _previewBytes = detected['previewPng'] as Uint8List; _usedFallback = detected['usedFallback'] as bool? ?? false; _imageW = (detected['imageW'] as num).toDouble(); @@ -1385,6 +1391,10 @@ class _CapPhotoReviewPageState extends State<_CapPhotoReviewPage> { if (localToken != _recalcToken) return; _dominantColor = Color(adjusted['dominantColor'] as int); _averageColor = Color(adjusted['averageColor'] as int); + _topDominantColors = ((adjusted['topDominantColors'] as List?) ?? + [adjusted['dominantColor']]) + .map((e) => Color(e as int)) + .toList(growable: false); _previewBytes = adjusted['previewPng'] as Uint8List; _selected = _mode == ColorExtractionMode.dominant ? _dominantColor @@ -1398,6 +1408,7 @@ class _CapPhotoReviewPageState extends State<_CapPhotoReviewPage> { const fallback = Colors.orange; _dominantColor = fallback; _averageColor = fallback; + _topDominantColors = [fallback]; _selected = fallback; _hexCtrl.text = _colorToHexStatic(_selected); } @@ -1478,6 +1489,39 @@ class _CapPhotoReviewPageState extends State<_CapPhotoReviewPage> { setState(() {}); }, ), + const SizedBox(height: 8), + Text( + 'Dominante Farben (Anteil) + Durchschnitt:', + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 6), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (int i = 0; i < _topDominantColors.length; i++) + _ColorPickChip( + label: 'D${i + 1}', + color: _topDominantColors[i], + onTap: () { + setState(() { + _selected = _topDominantColors[i]; + _hexCtrl.text = _colorToHexStatic(_selected); + }); + }, + ), + _ColorPickChip( + label: 'Mix', + color: _averageColor, + onTap: () { + setState(() { + _selected = _averageColor; + _hexCtrl.text = _colorToHexStatic(_selected); + }); + }, + ), + ], + ), Slider( value: _circleR, min: 0.08, @@ -1568,6 +1612,50 @@ String _colorToHexStatic(Color color) { return '#${color.toARGB32().toRadixString(16).substring(2).toUpperCase()}'; } +class _ColorPickChip extends StatelessWidget { + final String label; + final Color color; + final VoidCallback onTap; + + const _ColorPickChip({ + required this.label, + required this.color, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + borderRadius: BorderRadius.circular(20), + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.65), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: Colors.black12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 16, + height: 16, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(5), + border: Border.all(color: Colors.black26), + ), + ), + const SizedBox(width: 6), + Text(label), + ], + ), + ), + ); + } +} + class _AeroBackgroundAccents extends StatelessWidget { const _AeroBackgroundAccents(); @@ -2186,9 +2274,11 @@ Map _generateMosaicIsolate(Map request) { Map _extractCapFromPhotoIsolate(Uint8List sourceBytes) { final decoded = img.decodeImage(sourceBytes); if (decoded == null) { + final fallback = Colors.orange.toARGB32(); return { - 'dominantColor': Colors.orange.toARGB32(), - 'averageColor': Colors.orange.toARGB32(), + 'dominantColor': fallback, + 'averageColor': fallback, + 'topDominantColors': [fallback], 'usedFallback': true, 'circleX': 0.5, 'circleY': 0.5, @@ -2233,6 +2323,7 @@ Map _extractCapFromPhotoIsolate(Uint8List sourceBytes) { return { 'dominantColor': stats.dominantArgb, 'averageColor': stats.averageArgb, + 'topDominantColors': stats.topDominantArgbs, 'usedFallback': usedFallback, 'circleX': upscaledCircle.cx, 'circleY': upscaledCircle.cy, @@ -2248,9 +2339,11 @@ Map _extractCapFromAdjustedCircleIsolate( final sourceBytes = request['sourceBytes'] as Uint8List; final decoded = img.decodeImage(sourceBytes); if (decoded == null) { + final fallback = Colors.orange.toARGB32(); return { - 'dominantColor': Colors.orange.toARGB32(), - 'averageColor': Colors.orange.toARGB32(), + 'dominantColor': fallback, + 'averageColor': fallback, + 'topDominantColors': [fallback], 'previewPng': Uint8List.fromList( img.encodePng(img.Image(width: 1, height: 1), level: 1)), }; @@ -2296,6 +2389,7 @@ Map _extractCapFromAdjustedCircleIsolate( return { 'dominantColor': stats.dominantArgb, 'averageColor': stats.averageArgb, + 'topDominantColors': stats.topDominantArgbs, 'previewPng': Uint8List.fromList(img.encodePng(preview, level: 1)), }; } @@ -2445,7 +2539,11 @@ _CapColorStats _sampleCapColors(img.Image image, _DetectedCircle circle) { if (samples.isEmpty) { final fallback = const Color(0xFFFF9800).toARGB32(); - return _CapColorStats(averageArgb: fallback, dominantArgb: fallback); + return _CapColorStats( + averageArgb: fallback, + dominantArgb: fallback, + topDominantArgbs: [fallback], + ); } final lumValues = samples.map((s) => s.luminance).toList()..sort(); @@ -2461,9 +2559,14 @@ _CapColorStats _sampleCapColors(img.Image image, _DetectedCircle circle) { final usable = filtered.isEmpty ? samples : filtered; final avg = _weightedAverage(usable); - final dominant = _weightedDominant(usable); + final topDominant = _weightedDominantTop(usable, 3); + final dominant = topDominant.first; - return _CapColorStats(averageArgb: avg, dominantArgb: dominant); + return _CapColorStats( + averageArgb: avg, + dominantArgb: dominant, + topDominantArgbs: topDominant, + ); } void _drawCircle(img.Image image, double cx, double cy, double r) { @@ -2499,8 +2602,13 @@ class _DetectedCircle { class _CapColorStats { final int averageArgb; final int dominantArgb; + final List topDominantArgbs; - const _CapColorStats({required this.averageArgb, required this.dominantArgb}); + const _CapColorStats({ + required this.averageArgb, + required this.dominantArgb, + required this.topDominantArgbs, + }); } class _ColorSample { @@ -2561,7 +2669,7 @@ int _weightedAverage(List<_ColorSample> samples) { ).toARGB32(); } -int _weightedDominant(List<_ColorSample> samples) { +List _weightedDominantTop(List<_ColorSample> samples, int count) { final bins = {}; for (final s in samples) { final hsv = _rgbToHsv(s.r, s.g, s.b); @@ -2573,18 +2681,25 @@ int _weightedDominant(List<_ColorSample> samples) { bucket.add(s, s.weight); } - _WeightedRgb? best; - for (final b in bins.values) { - if (best == null || b.weight > best.weight) best = b; - } - if (best == null || best.weight <= 0) return _weightedAverage(samples); + if (bins.isEmpty) return [_weightedAverage(samples)]; - return Color.fromARGB( - 255, - (best.r / best.weight).round().clamp(0, 255), - (best.g / best.weight).round().clamp(0, 255), - (best.b / best.weight).round().clamp(0, 255), - ).toARGB32(); + final sorted = bins.values.toList() + ..sort((a, b) => b.weight.compareTo(a.weight)); + + final take = math.max(1, math.min(count, sorted.length)); + return sorted.take(take).map((b) { + if (b.weight <= 0) return _weightedAverage(samples); + return Color.fromARGB( + 255, + (b.r / b.weight).round().clamp(0, 255), + (b.g / b.weight).round().clamp(0, 255), + (b.b / b.weight).round().clamp(0, 255), + ).toARGB32(); + }).toList(growable: false); +} + +int _weightedDominant(List<_ColorSample> samples) { + return _weightedDominantTop(samples, 1).first; } List _rgbToHsv(int r, int g, int b) {