diff --git a/lib/main.dart b/lib/main.dart index 2cf0202..0b062bd 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -150,6 +150,7 @@ class _MosaicHomePageState extends State { Future _captureCapPhoto() async { final XFile? captured = await _picker.pickImage( source: ImageSource.camera, + preferredCameraDevice: CameraDevice.rear, imageQuality: 95, maxWidth: 1800, ); @@ -159,10 +160,17 @@ class _MosaicHomePageState extends State { final detected = await compute(_extractCapFromPhotoIsolate, bytes); if (!mounted) return; - final dominantColor = Color(detected['dominantColor'] as int); - final averageColor = Color(detected['averageColor'] as int); - final previewBytes = detected['previewPng'] as Uint8List; + Color dominantColor = Color(detected['dominantColor'] as int); + Color averageColor = Color(detected['averageColor'] as int); + Uint8List previewBytes = detected['previewPng'] as Uint8List; final bool usedFallback = detected['usedFallback'] as bool? ?? false; + final imageW = (detected['imageW'] as num).toDouble(); + final imageH = (detected['imageH'] as num).toDouble(); + double circleX = (detected['circleX'] as num).toDouble() / imageW; + double circleY = (detected['circleY'] as num).toDouble() / imageH; + double circleR = + (detected['circleR'] as num).toDouble() / math.min(imageW, imageH); + ColorExtractionMode mode = _colorExtractionMode; Color selected = mode == ColorExtractionMode.dominant ? dominantColor : averageColor; @@ -174,6 +182,26 @@ class _MosaicHomePageState extends State { builder: (ctx) { return StatefulBuilder( builder: (ctx, setDialogState) { + Future recalculate() async { + final adjusted = await compute( + _extractCapFromAdjustedCircleIsolate, + { + 'sourceBytes': bytes, + 'circleX': circleX, + 'circleY': circleY, + 'circleR': circleR, + }, + ); + dominantColor = Color(adjusted['dominantColor'] as int); + averageColor = Color(adjusted['averageColor'] as int); + previewBytes = adjusted['previewPng'] as Uint8List; + selected = mode == ColorExtractionMode.dominant + ? dominantColor + : averageColor; + _photoCapHexCtrl.text = _colorToHex(selected); + setDialogState(() {}); + } + return AlertDialog( title: const Text('Deckel fotografieren'), content: SingleChildScrollView( @@ -189,8 +217,8 @@ class _MosaicHomePageState extends State { const SizedBox(height: 8), Text( usedFallback - ? 'Kreis wurde per Kanten-Fallback erkannt.' - : 'Kreis wurde automatisch auf dem Deckel erkannt.', + ? 'Kreiserkennung per Fallback. Bei Bedarf Kreis manuell korrigieren.' + : 'Kreis automatisch erkannt. Optional unten feinjustieren.', style: Theme.of(context).textTheme.bodySmall, ), const SizedBox(height: 8), @@ -198,11 +226,11 @@ class _MosaicHomePageState extends State { segments: const [ ButtonSegment( value: ColorExtractionMode.dominant, - label: Text('Dominante Farbe'), + label: Text('Dominant (robust)'), ), ButtonSegment( value: ColorExtractionMode.average, - label: Text('Durchschnitt'), + label: Text('Gewichteter Mittelwert'), ), ], selected: {mode}, @@ -217,10 +245,42 @@ class _MosaicHomePageState extends State { ), const SizedBox(height: 8), Text( - 'Nur Pixel innerhalb des erkannten Kreisinneren werden ausgewertet.', + 'Robuste Analyse: Schatten/Outlier werden reduziert, nur geeignete Pixel im Kreis werden gewichtet.', style: Theme.of(context).textTheme.bodySmall, ), - const SizedBox(height: 12), + const SizedBox(height: 8), + const Text('Manuelle Kreis-Korrektur', + style: TextStyle(fontWeight: FontWeight.w600)), + Slider( + value: circleX.clamp(0.15, 0.85), + min: 0.15, + max: 0.85, + label: 'X', + onChanged: (v) => setDialogState(() => circleX = v), + ), + Slider( + value: circleY.clamp(0.15, 0.85), + min: 0.15, + max: 0.85, + label: 'Y', + onChanged: (v) => setDialogState(() => circleY = v), + ), + Slider( + value: circleR.clamp(0.12, 0.48), + min: 0.12, + max: 0.48, + label: 'Radius', + onChanged: (v) => setDialogState(() => circleR = v), + ), + Align( + alignment: Alignment.centerRight, + child: OutlinedButton.icon( + onPressed: recalculate, + icon: const Icon(Icons.tune), + label: const Text('Kreis anwenden & neu berechnen'), + ), + ), + const SizedBox(height: 8), Row( children: [ Container( @@ -1243,6 +1303,11 @@ Map _extractCapFromPhotoIsolate(Uint8List sourceBytes) { 'dominantColor': Colors.orange.toARGB32(), 'averageColor': Colors.orange.toARGB32(), 'usedFallback': true, + 'circleX': 0.5, + 'circleY': 0.5, + 'circleR': 0.3, + 'imageW': 1, + 'imageH': 1, 'previewPng': Uint8List.fromList( img.encodePng(img.Image(width: 1, height: 1), level: 1)), }; @@ -1276,7 +1341,74 @@ Map _extractCapFromPhotoIsolate(Uint8List sourceBytes) { ); final stats = _sampleCapColors(decoded, upscaledCircle); + final preview = _buildCirclePreview(analysis, detected); + return { + 'dominantColor': stats.dominantArgb, + 'averageColor': stats.averageArgb, + 'usedFallback': usedFallback, + 'circleX': upscaledCircle.cx, + 'circleY': upscaledCircle.cy, + 'circleR': upscaledCircle.r, + 'imageW': decoded.width, + 'imageH': decoded.height, + 'previewPng': Uint8List.fromList(img.encodePng(preview, level: 1)), + }; +} + +Map _extractCapFromAdjustedCircleIsolate( + Map request) { + final sourceBytes = request['sourceBytes'] as Uint8List; + final decoded = img.decodeImage(sourceBytes); + if (decoded == null) { + return { + 'dominantColor': Colors.orange.toARGB32(), + 'averageColor': Colors.orange.toARGB32(), + 'previewPng': Uint8List.fromList( + img.encodePng(img.Image(width: 1, height: 1), level: 1)), + }; + } + + final cx = ((request['circleX'] as num).toDouble() * decoded.width) + .clamp(0.0, decoded.width.toDouble()); + final cy = ((request['circleY'] as num).toDouble() * decoded.height) + .clamp(0.0, decoded.height.toDouble()); + final r = ((request['circleR'] as num).toDouble() * + math.min(decoded.width, decoded.height)) + .clamp(8.0, math.min(decoded.width, decoded.height) * 0.49); + + final circle = _DetectedCircle(cx: cx, cy: cy, r: r); + final stats = _sampleCapColors(decoded, circle); + + const int analysisMaxSize = 480; + final scale = decoded.width >= decoded.height + ? (decoded.width > analysisMaxSize + ? analysisMaxSize / decoded.width + : 1.0) + : (decoded.height > analysisMaxSize + ? analysisMaxSize / decoded.height + : 1.0); + final analysis = scale < 1.0 + ? img.copyResize(decoded, + width: (decoded.width * scale).round(), + height: (decoded.height * scale).round(), + interpolation: img.Interpolation.average) + : decoded; + + final preview = _buildCirclePreview( + analysis, + _DetectedCircle( + cx: circle.cx * scale, cy: circle.cy * scale, r: circle.r * scale), + ); + + return { + 'dominantColor': stats.dominantArgb, + 'averageColor': stats.averageArgb, + 'previewPng': Uint8List.fromList(img.encodePng(preview, level: 1)), + }; +} + +img.Image _buildCirclePreview(img.Image analysis, _DetectedCircle detected) { final preview = img.copyResize(analysis, width: math.min(analysis.width, 320), height: (math.min(analysis.width, 320) * analysis.height / analysis.width) @@ -1284,13 +1416,7 @@ Map _extractCapFromPhotoIsolate(Uint8List sourceBytes) { final previewScale = preview.width / analysis.width; _drawCircle(preview, detected.cx * previewScale, detected.cy * previewScale, detected.r * previewScale); - - return { - 'dominantColor': stats.dominantArgb, - 'averageColor': stats.averageArgb, - 'usedFallback': usedFallback, - 'previewPng': Uint8List.fromList(img.encodePng(preview, level: 1)), - }; + return preview; } _DetectedCircle? _detectCapCircle(img.Image image) { @@ -1371,13 +1497,9 @@ _DetectedCircle _fallbackCapCircle(int width, int height) { } _CapColorStats _sampleCapColors(img.Image image, _DetectedCircle circle) { - final buckets = {}; - double sumR = 0; - double sumG = 0; - double sumB = 0; - int included = 0; + final samples = <_ColorSample>[]; - final insetRadius = circle.r * 0.78; + final insetRadius = circle.r * 0.76; final r2 = insetRadius * insetRadius; final minX = math.max(0, (circle.cx - insetRadius).floor()); final maxX = math.min(image.width - 1, (circle.cx + insetRadius).ceil()); @@ -1388,47 +1510,50 @@ _CapColorStats _sampleCapColors(img.Image image, _DetectedCircle circle) { final dy = y - circle.cy; for (int x = minX; x <= maxX; x++) { final dx = x - circle.cx; - if ((dx * dx) + (dy * dy) > r2) continue; + final dist2 = (dx * dx) + (dy * dy); + if (dist2 > r2) continue; final p = image.getPixel(x, y); final r = p.r.toInt(); final g = p.g.toInt(); final b = p.b.toInt(); + final hsv = _rgbToHsv(r, g, b); + final lum = 0.2126 * r + 0.7152 * g + 0.0722 * b; + final radial = 1.0 - math.sqrt(dist2) / insetRadius; - sumR += r; - sumG += g; - sumB += b; - included++; - - final bucketKey = ((r ~/ 16) << 8) | ((g ~/ 16) << 4) | (b ~/ 16); - final bucket = buckets.putIfAbsent(bucketKey, () => _RgbBucket()); - bucket.add(r, g, b); + samples.add(_ColorSample( + r: r, + g: g, + b: b, + saturation: hsv[1], + value: hsv[2], + luminance: lum, + radialWeight: radial.clamp(0.1, 1.0), + )); } } - if (included == 0) { + if (samples.isEmpty) { final fallback = const Color(0xFFFF9800).toARGB32(); return _CapColorStats(averageArgb: fallback, dominantArgb: fallback); } - final average = Color.fromARGB(255, (sumR / included).round(), - (sumG / included).round(), (sumB / included).round()) - .toARGB32(); + final lumValues = samples.map((s) => s.luminance).toList()..sort(); + final lowLum = _percentile(lumValues, 0.18); + final highLum = _percentile(lumValues, 0.98); - _RgbBucket? dominant; - for (final bucket in buckets.values) { - if (dominant == null || bucket.count > dominant.count) dominant = bucket; - } - final dominantArgb = dominant == null - ? average - : Color.fromARGB( - 255, - (dominant.r / dominant.count).round(), - (dominant.g / dominant.count).round(), - (dominant.b / dominant.count).round()) - .toARGB32(); + final filtered = samples.where((s) { + if (s.luminance < lowLum || s.luminance > highLum) return false; + if (s.value < 0.18) return false; + return true; + }).toList(); - return _CapColorStats(averageArgb: average, dominantArgb: dominantArgb); + final usable = filtered.isEmpty ? samples : filtered; + + final avg = _weightedAverage(usable); + final dominant = _weightedDominant(usable); + + return _CapColorStats(averageArgb: avg, dominantArgb: dominant); } void _drawCircle(img.Image image, double cx, double cy, double r) { @@ -1468,17 +1593,39 @@ class _CapColorStats { const _CapColorStats({required this.averageArgb, required this.dominantArgb}); } -class _RgbBucket { - int count = 0; - int r = 0; - int g = 0; - int b = 0; +class _ColorSample { + final int r; + final int g; + final int b; + final double saturation; + final double value; + final double luminance; + final double radialWeight; - void add(int nr, int ng, int nb) { - count++; - r += nr; - g += ng; - b += nb; + const _ColorSample({ + required this.r, + required this.g, + required this.b, + required this.saturation, + required this.value, + required this.luminance, + required this.radialWeight, + }); + + double get weight => radialWeight * (0.55 + saturation * 0.75); +} + +class _WeightedRgb { + double weight = 0; + double r = 0; + double g = 0; + double b = 0; + + void add(_ColorSample s, double w) { + weight += w; + r += s.r * w; + g += s.g * w; + b += s.b * w; } } @@ -1489,6 +1636,82 @@ class _Candidate { const _Candidate({required this.index, required this.distance}); } +int _weightedAverage(List<_ColorSample> samples) { + final acc = _WeightedRgb(); + for (final s in samples) { + final w = s.weight; + acc.add(s, w); + } + if (acc.weight <= 0) return const Color(0xFFFF9800).toARGB32(); + return Color.fromARGB( + 255, + (acc.r / acc.weight).round().clamp(0, 255), + (acc.g / acc.weight).round().clamp(0, 255), + (acc.b / acc.weight).round().clamp(0, 255), + ).toARGB32(); +} + +int _weightedDominant(List<_ColorSample> samples) { + final bins = {}; + for (final s in samples) { + final hsv = _rgbToHsv(s.r, s.g, s.b); + final hBin = (hsv[0] / 20).floor().clamp(0, 17); + final sBin = (hsv[1] * 4).floor().clamp(0, 3); + final vBin = (hsv[2] * 4).floor().clamp(0, 3); + final key = (hBin << 6) | (sBin << 3) | vBin; + final bucket = bins.putIfAbsent(key, () => _WeightedRgb()); + 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); + + 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(); +} + +List _rgbToHsv(int r, int g, int b) { + final rf = r / 255.0; + final gf = g / 255.0; + final bf = b / 255.0; + final maxC = math.max(rf, math.max(gf, bf)); + final minC = math.min(rf, math.min(gf, bf)); + final delta = maxC - minC; + + double h; + if (delta == 0) { + h = 0; + } else if (maxC == rf) { + h = 60 * (((gf - bf) / delta) % 6); + } else if (maxC == gf) { + h = 60 * (((bf - rf) / delta) + 2); + } else { + h = 60 * (((rf - gf) / delta) + 4); + } + if (h < 0) h += 360; + + final s = maxC == 0 ? 0.0 : delta / maxC; + final v = maxC; + return [h, s, v]; +} + +double _percentile(List sorted, double p) { + if (sorted.isEmpty) return 0; + final pos = (sorted.length - 1) * p.clamp(0.0, 1.0); + final lo = pos.floor(); + final hi = pos.ceil(); + if (lo == hi) return sorted[lo]; + final t = pos - lo; + return sorted[lo] * (1 - t) + sorted[hi] * t; +} + double _mix(double a, double b, double t) => a + (b - a) * t; List _argbToLab(int argb) {