feat(cap-detect): show top 3 dominant colors + mixed average
This commit is contained in:
155
lib/main.dart
155
lib/main.dart
@@ -435,6 +435,7 @@ class _MosaicHomePageState extends State<MosaicHomePage>
|
||||
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<Color> _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<String, dynamic> _generateMosaicIsolate(Map<String, dynamic> request) {
|
||||
Map<String, dynamic> _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<String, dynamic> _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<String, dynamic> _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<String, dynamic> _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<int> 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<int> _weightedDominantTop(List<_ColorSample> samples, int count) {
|
||||
final bins = <int, _WeightedRgb>{};
|
||||
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<double> _rgbToHsv(int r, int g, int b) {
|
||||
|
||||
Reference in New Issue
Block a user