import 'dart:async'; import 'dart:math' as math; import 'dart:typed_data'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_colorpicker/flutter_colorpicker.dart'; import 'package:image/image.dart' as img; import 'package:image_picker/image_picker.dart'; void main() { runApp(const KorkenMosaicApp()); } class KorkenMosaicApp extends StatelessWidget { const KorkenMosaicApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( title: 'Korken Mosaic', theme: ThemeData(colorSchemeSeed: Colors.teal, useMaterial3: true), home: const MosaicHomePage(), ); } } enum StylePreset { realistisch, ausgewogen, kuenstlerisch } class MosaicHomePage extends StatefulWidget { const MosaicHomePage({super.key}); @override State createState() => _MosaicHomePageState(); } class _MosaicHomePageState extends State { final ImagePicker _picker = ImagePicker(); final TextEditingController _photoCapNameCtrl = TextEditingController(); final TextEditingController _photoCapHexCtrl = TextEditingController(); final TextEditingController _gridWidthCtrl = TextEditingController(text: '40'); final TextEditingController _gridHeightCtrl = TextEditingController(text: '30'); final TextEditingController _capSizeCtrl = TextEditingController(text: '12'); Uint8List? _sourceImageBytes; MosaicResult? _result; bool _useCapSize = false; bool _isGenerating = false; Timer? _debounceTimer; int _generationToken = 0; double _fidelityStructure = 0.5; double _ditheringStrength = 0.35; double _edgeEmphasis = 0.4; double _colorVariation = 0.3; StylePreset _selectedPreset = StylePreset.ausgewogen; final List _palette = [ CapColor(name: 'White', color: const Color(0xFFF2F2F2)), CapColor(name: 'Black', color: const Color(0xFF222222)), CapColor(name: 'Red', color: const Color(0xFFD84343)), CapColor(name: 'Blue', color: const Color(0xFF3F6FD8)), CapColor(name: 'Green', color: const Color(0xFF4FAE63)), ]; @override void initState() { super.initState(); _gridWidthCtrl.addListener(_scheduleRegenerate); _gridHeightCtrl.addListener(_scheduleRegenerate); _capSizeCtrl.addListener(_scheduleRegenerate); } @override void dispose() { _debounceTimer?.cancel(); _gridWidthCtrl.dispose(); _gridHeightCtrl.dispose(); _capSizeCtrl.dispose(); _photoCapNameCtrl.dispose(); _photoCapHexCtrl.dispose(); super.dispose(); } Future _pickImage() async { final XFile? picked = await _picker.pickImage(source: ImageSource.gallery); if (picked == null) return; final bytes = await picked.readAsBytes(); setState(() { _sourceImageBytes = bytes; _result = null; }); } Future _captureCapPhoto() async { final XFile? captured = await _picker.pickImage( source: ImageSource.camera, imageQuality: 95, maxWidth: 1800, ); if (captured == null) return; final bytes = await captured.readAsBytes(); final detected = await compute(_extractCapFromCenterIsolate, bytes); if (!mounted) return; Color selected = Color(detected['color'] as int); final previewBytes = detected['previewPng'] as Uint8List; _photoCapNameCtrl.text = 'Fotografierter Deckel'; _photoCapHexCtrl.text = _colorToHex(selected); showDialog( context: context, builder: (ctx) { return StatefulBuilder( builder: (ctx, setDialogState) { return AlertDialog( title: const Text('Deckel fotografieren'), content: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ ClipRRect( borderRadius: BorderRadius.circular(12), child: Image.memory( previewBytes, width: 220, height: 220, fit: BoxFit.cover, ), ), const SizedBox(height: 8), Text( 'Farbe wird aus dem mittigen Kreisbereich erkannt.', style: Theme.of(context).textTheme.bodySmall, ), const SizedBox(height: 12), Row( children: [ Container( width: 36, height: 36, decoration: BoxDecoration( color: selected, border: Border.all(color: Colors.black26), borderRadius: BorderRadius.circular(8), ), ), const SizedBox(width: 10), Expanded( child: Text( 'Erkannte Farbe: ${_colorToHex(selected)}', style: const TextStyle(fontWeight: FontWeight.w600), ), ), ], ), const SizedBox(height: 10), TextField( controller: _photoCapNameCtrl, decoration: const InputDecoration(labelText: 'Name'), ), const SizedBox(height: 8), TextField( controller: _photoCapHexCtrl, decoration: const InputDecoration(labelText: 'Hex (#RRGGBB)'), onChanged: (value) { final parsed = _parseHex(value); if (parsed == null) return; setDialogState(() => selected = parsed); }, ), ], ), ), actions: [ TextButton( onPressed: () => Navigator.pop(ctx), child: const Text('Abbrechen'), ), FilledButton( onPressed: () { final name = _photoCapNameCtrl.text.trim(); if (name.isEmpty) return; final parsed = _parseHex(_photoCapHexCtrl.text); setState(() { _palette.add( CapColor(name: name, color: parsed ?? selected), ); }); Navigator.pop(ctx); _scheduleRegenerate(); }, child: const Text('Zur Palette hinzufügen'), ), ], ); }, ); }, ); } void _scheduleRegenerate() { if (_sourceImageBytes == null || _result == null) return; _debounceTimer?.cancel(); _debounceTimer = Timer(const Duration(milliseconds: 250), _generate); } void _applyPreset(StylePreset preset) { setState(() { _selectedPreset = preset; switch (preset) { case StylePreset.realistisch: _fidelityStructure = 0.2; _ditheringStrength = 0.15; _edgeEmphasis = 0.25; _colorVariation = 0.1; break; case StylePreset.ausgewogen: _fidelityStructure = 0.5; _ditheringStrength = 0.35; _edgeEmphasis = 0.4; _colorVariation = 0.3; break; case StylePreset.kuenstlerisch: _fidelityStructure = 0.82; _ditheringStrength = 0.75; _edgeEmphasis = 0.75; _colorVariation = 0.78; break; } }); _scheduleRegenerate(); } void _onStyleChanged() { if (_selectedPreset != StylePreset.ausgewogen) { setState(() => _selectedPreset = StylePreset.ausgewogen); } _scheduleRegenerate(); } void _addCapDialog() { Color selected = Colors.orange; final nameCtrl = TextEditingController(); final hexCtrl = TextEditingController(text: _colorToHex(selected)); showDialog( context: context, builder: (ctx) { return AlertDialog( title: const Text('Add Bottle Cap Color'), content: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, children: [ TextField( controller: nameCtrl, decoration: const InputDecoration(labelText: 'Name'), ), const SizedBox(height: 8), TextField( controller: hexCtrl, decoration: const InputDecoration(labelText: 'Hex (#RRGGBB)'), onChanged: (value) { final parsed = _parseHex(value); if (parsed != null) selected = parsed; }, ), const SizedBox(height: 12), ColorPicker( pickerColor: selected, onColorChanged: (c) { selected = c; hexCtrl.text = _colorToHex(c); }, ), ], ), ), actions: [ TextButton( onPressed: () => Navigator.pop(ctx), child: const Text('Cancel'), ), FilledButton( onPressed: () { final name = nameCtrl.text.trim(); if (name.isEmpty) return; setState(() { _palette.add( CapColor( name: name, color: _parseHex(hexCtrl.text) ?? selected), ); }); Navigator.pop(ctx); _scheduleRegenerate(); }, child: const Text('Add'), ), ], ); }, ); } Future _generate() async { if (_sourceImageBytes == null || _palette.isEmpty) return; final int gridW = math.max(1, int.tryParse(_gridWidthCtrl.text) ?? 40); final int gridH = math.max(1, int.tryParse(_gridHeightCtrl.text) ?? 30); final int capSize = math.max(1, int.tryParse(_capSizeCtrl.text) ?? 12); final token = ++_generationToken; setState(() => _isGenerating = true); final payload = { 'source': _sourceImageBytes!, 'useCapSize': _useCapSize, 'gridW': gridW, 'gridH': gridH, 'capSize': capSize, 'fidelityStructure': _fidelityStructure, 'ditheringStrength': _ditheringStrength, 'edgeEmphasis': _edgeEmphasis, 'colorVariation': _colorVariation, 'palette': _palette .map((p) => {'name': p.name, 'value': p.color.toARGB32()}) .toList(growable: false), }; final out = await compute(_generateMosaicIsolate, payload); if (!mounted || token != _generationToken) return; final palette = (out['palette'] as List) .map( (e) => CapColor( name: e['name'] as String, color: Color(e['value'] as int)), ) .toList(growable: false); final countsList = (out['counts'] as List).cast(); final counts = {}; for (int i = 0; i < palette.length; i++) { counts[palette[i]] = countsList[i]; } final sortedCounts = counts.entries.toList() ..sort((a, b) => b.value.compareTo(a.value)); setState(() { _result = MosaicResult( width: out['width'] as int, height: out['height'] as int, assignments: (out['assignments'] as List).cast(), palette: palette, counts: counts, sortedCounts: sortedCounts, previewPng: out['previewPng'] as Uint8List, ); _isGenerating = false; }); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('Bottle-Cap Mosaic Prototype')), floatingActionButton: FloatingActionButton.extended( onPressed: _isGenerating ? null : _generate, icon: _isGenerating ? const SizedBox( width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.auto_fix_high), label: Text(_isGenerating ? 'Generating…' : 'Generate Mosaic'), ), body: Padding( padding: const EdgeInsets.all(12), child: ListView( children: [ Wrap( runSpacing: 8, spacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [ FilledButton.icon( onPressed: _pickImage, icon: const Icon(Icons.image_outlined), label: const Text('Import target image'), ), if (_sourceImageBytes != null) const Text('Image loaded ✅'), ], ), const SizedBox(height: 12), SegmentedButton( segments: const [ ButtonSegment(value: false, label: Text('Grid W x H')), ButtonSegment(value: true, label: Text('Cap size (px)')), ], selected: {_useCapSize}, onSelectionChanged: (s) { setState(() => _useCapSize = s.first); _scheduleRegenerate(); }, ), const SizedBox(height: 8), if (!_useCapSize) Row( children: [ Expanded( child: TextField( controller: _gridWidthCtrl, keyboardType: TextInputType.number, decoration: const InputDecoration(labelText: 'Grid Width'), ), ), const SizedBox(width: 8), Expanded( child: TextField( controller: _gridHeightCtrl, keyboardType: TextInputType.number, decoration: const InputDecoration(labelText: 'Grid Height'), ), ), ], ) else TextField( controller: _capSizeCtrl, keyboardType: TextInputType.number, decoration: const InputDecoration( labelText: 'Approx cap size in source image (pixels)', ), ), const SizedBox(height: 16), const Text( 'Style Preset', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), ), const SizedBox(height: 8), SegmentedButton( segments: const [ ButtonSegment( value: StylePreset.realistisch, label: Text('Realistisch'), ), ButtonSegment( value: StylePreset.ausgewogen, label: Text('Ausgewogen'), ), ButtonSegment( value: StylePreset.kuenstlerisch, label: Text('Künstlerisch'), ), ], selected: {_selectedPreset}, onSelectionChanged: (s) => _applyPreset(s.first), ), const SizedBox(height: 8), Card( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: Column( children: [ _SliderRow( label: 'Fidelity ↔ Structure', leftLabel: 'Fidelity', rightLabel: 'Structure', value: _fidelityStructure, onChanged: (v) { setState(() => _fidelityStructure = v); _onStyleChanged(); }, ), _SliderRow( label: 'Dithering strength', leftLabel: 'Off', rightLabel: 'Strong', value: _ditheringStrength, onChanged: (v) { setState(() => _ditheringStrength = v); _onStyleChanged(); }, ), _SliderRow( label: 'Edge emphasis', leftLabel: 'Soft', rightLabel: 'Crisp', value: _edgeEmphasis, onChanged: (v) { setState(() => _edgeEmphasis = v); _onStyleChanged(); }, ), _SliderRow( label: 'Color tolerance / variation', leftLabel: 'Strict', rightLabel: 'Varied', value: _colorVariation, onChanged: (v) { setState(() => _colorVariation = v); _onStyleChanged(); }, ), ], ), ), ), const SizedBox(height: 16), Row( children: [ const Expanded( child: Text( 'Cap Palette', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), ), ), OutlinedButton.icon( onPressed: _captureCapPhoto, icon: const Icon(Icons.photo_camera_outlined), label: const Text('Deckel fotografieren'), ), const SizedBox(width: 8), IconButton( onPressed: _addCapDialog, icon: const Icon(Icons.add_circle_outline), tooltip: 'Farbe manuell hinzufügen', ), ], ), Wrap( spacing: 8, runSpacing: 8, children: [ for (int i = 0; i < _palette.length; i++) Chip( avatar: CircleAvatar(backgroundColor: _palette[i].color), label: Text(_palette[i].name), onDeleted: _palette.length <= 1 ? null : () { setState(() => _palette.removeAt(i)); _scheduleRegenerate(); }, ), ], ), const SizedBox(height: 16), if (_result != null) ...[ Text( 'Preview (${_result!.width} x ${_result!.height})', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), ), const SizedBox(height: 8), RepaintBoundary( child: AspectRatio( aspectRatio: _result!.width / _result!.height, child: Image.memory( _result!.previewPng, fit: BoxFit.fill, filterQuality: FilterQuality.none, gaplessPlayback: true, ), ), ), const SizedBox(height: 16), const Text( 'Bill of Materials', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), ), ..._result!.sortedCounts.map( (e) => ListTile( dense: true, leading: CircleAvatar(backgroundColor: e.key.color), title: Text(e.key.name), trailing: Text('${e.value} caps'), ), ), ], ], ), ), ); } Color? _parseHex(String raw) { final hex = raw.trim().replaceFirst('#', ''); if (hex.length != 6) return null; final value = int.tryParse(hex, radix: 16); if (value == null) return null; return Color(0xFF000000 | value); } String _colorToHex(Color color) { return '#${color.toARGB32().toRadixString(16).substring(2).toUpperCase()}'; } } class _SliderRow extends StatelessWidget { final String label; final String leftLabel; final String rightLabel; final double value; final ValueChanged onChanged; const _SliderRow({ required this.label, required this.leftLabel, required this.rightLabel, required this.value, required this.onChanged, }); @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(label, style: const TextStyle(fontWeight: FontWeight.w600)), Slider(value: value, onChanged: onChanged), Padding( padding: const EdgeInsets.only(bottom: 8), child: Row( children: [ Text(leftLabel, style: Theme.of(context).textTheme.bodySmall), const Spacer(), Text(rightLabel, style: Theme.of(context).textTheme.bodySmall), ], ), ), ], ); } } class CapColor { final String name; final Color color; const CapColor({required this.name, required this.color}); } class MosaicResult { final int width; final int height; final List assignments; final List palette; final Map counts; final List> sortedCounts; final Uint8List previewPng; MosaicResult({ required this.width, required this.height, required this.assignments, required this.palette, required this.counts, required this.sortedCounts, required this.previewPng, }); } Map _generateMosaicIsolate(Map request) { final source = request['source'] as Uint8List; final useCapSize = request['useCapSize'] as bool; final defaultGridW = request['gridW'] as int; final defaultGridH = request['gridH'] as int; final capSize = request['capSize'] as int; final fidelityStructure = (request['fidelityStructure'] as num).toDouble(); final ditheringStrength = (request['ditheringStrength'] as num).toDouble(); final edgeEmphasis = (request['edgeEmphasis'] as num).toDouble(); final colorVariation = (request['colorVariation'] as num).toDouble(); final paletteRaw = (request['palette'] as List).cast(); final decoded = img.decodeImage(source); if (decoded == null) { return { 'width': 1, 'height': 1, 'assignments': [0], 'counts': [1], 'palette': paletteRaw, 'previewPng': Uint8List.fromList( img.encodePng(img.Image(width: 1, height: 1)), ), }; } final gridW = useCapSize ? math.max(1, (decoded.width / capSize).round()) : defaultGridW; final gridH = useCapSize ? math.max(1, (decoded.height / capSize).round()) : defaultGridH; final interpolation = fidelityStructure < 0.4 ? img.Interpolation.average : img.Interpolation.linear; final scaled = img.copyResize( decoded, width: gridW, height: gridH, interpolation: interpolation, ); final pixelCount = gridW * gridH; final workingR = List.filled(pixelCount, 0); final workingG = List.filled(pixelCount, 0); final workingB = List.filled(pixelCount, 0); final luminance = List.filled(pixelCount, 0); for (int y = 0; y < gridH; y++) { for (int x = 0; x < gridW; x++) { final idx = y * gridW + x; final pix = scaled.getPixel(x, y); final r = pix.r.toDouble(); final g = pix.g.toDouble(); final b = pix.b.toDouble(); workingR[idx] = r; workingG[idx] = g; workingB[idx] = b; luminance[idx] = 0.2126 * r + 0.7152 * g + 0.0722 * b; } } final edgeStrength = List.filled(pixelCount, 0); for (int y = 0; y < gridH; y++) { final ym = y > 0 ? y - 1 : y; final yp = y + 1 < gridH ? y + 1 : y; for (int x = 0; x < gridW; x++) { final xm = x > 0 ? x - 1 : x; final xp = x + 1 < gridW ? x + 1 : x; final left = luminance[y * gridW + xm]; final right = luminance[y * gridW + xp]; final up = luminance[ym * gridW + x]; final down = luminance[yp * gridW + x]; final gx = right - left; final gy = down - up; edgeStrength[y * gridW + x] = (math.sqrt(gx * gx + gy * gy) / 255.0).clamp(0.0, 1.0); } } if (fidelityStructure > 0.001) { final blurredR = List.filled(pixelCount, 0); final blurredG = List.filled(pixelCount, 0); final blurredB = List.filled(pixelCount, 0); for (int y = 0; y < gridH; y++) { for (int x = 0; x < gridW; x++) { double sr = 0, sg = 0, sb = 0; int c = 0; for (int ny = math.max(0, y - 1); ny <= math.min(gridH - 1, y + 1); ny++) { for (int nx = math.max(0, x - 1); nx <= math.min(gridW - 1, x + 1); nx++) { final ni = ny * gridW + nx; sr += workingR[ni]; sg += workingG[ni]; sb += workingB[ni]; c++; } } final idx = y * gridW + x; blurredR[idx] = sr / c; blurredG[idx] = sg / c; blurredB[idx] = sb / c; } } for (int i = 0; i < pixelCount; i++) { final smoothBlend = fidelityStructure * (1.0 - edgeStrength[i] * 0.75); workingR[i] = _mix(workingR[i], blurredR[i], smoothBlend); workingG[i] = _mix(workingG[i], blurredG[i], smoothBlend); workingB[i] = _mix(workingB[i], blurredB[i], smoothBlend); } } final paletteValues = paletteRaw.map((e) => e['value'] as int).toList(); final paletteLab = paletteValues.map(_argbToLab).toList(growable: false); final assignments = List.filled(pixelCount, 0); final counts = List.filled(paletteValues.length, 0); final errorR = List.filled(pixelCount, 0); final errorG = List.filled(pixelCount, 0); final errorB = List.filled(pixelCount, 0); final labCache = >{}; for (int y = 0; y < gridH; y++) { for (int x = 0; x < gridW; x++) { final idx = y * gridW + x; final srcR = (workingR[idx] + errorR[idx]).clamp(0.0, 255.0); final srcG = (workingG[idx] + errorG[idx]).clamp(0.0, 255.0); final srcB = (workingB[idx] + errorB[idx]).clamp(0.0, 255.0); final rgb = ((srcR.round() & 0xFF) << 16) | ((srcG.round() & 0xFF) << 8) | (srcB.round() & 0xFF); final srcLab = labCache.putIfAbsent(rgb, () => _rgbToLab(rgb)); int bestIdx = 0; double bestDistance = double.infinity; final candidates = <_Candidate>[]; final localVar = colorVariation * (1.0 - edgeStrength[idx]).clamp(0.0, 1.0); final tolerance = 2.0 + localVar * 22.0; for (int i = 0; i < paletteLab.length; i++) { final lDiff = (srcLab[0] - paletteLab[i][0]).abs(); final d = _deltaE76(srcLab, paletteLab[i]) + (edgeEmphasis * edgeStrength[idx] * lDiff * 0.35); if (d < bestDistance) { bestDistance = d; bestIdx = i; } } for (int i = 0; i < paletteLab.length; i++) { final lDiff = (srcLab[0] - paletteLab[i][0]).abs(); final d = _deltaE76(srcLab, paletteLab[i]) + (edgeEmphasis * edgeStrength[idx] * lDiff * 0.35); if (d <= bestDistance + tolerance) { candidates.add(_Candidate(index: i, distance: d)); } } int chosen = bestIdx; if (localVar > 0.001 && candidates.length > 1) { candidates.sort((a, b) => a.distance.compareTo(b.distance)); final usable = math.min(candidates.length, 4); final h = ((x + 1) * 73856093) ^ ((y + 1) * 19349663); final pick = h.abs() % usable; chosen = candidates[pick].index; } assignments[idx] = chosen; counts[chosen]++; if (ditheringStrength > 0.001) { final argb = paletteValues[chosen]; final qr = ((argb >> 16) & 0xFF).toDouble(); final qg = ((argb >> 8) & 0xFF).toDouble(); final qb = (argb & 0xFF).toDouble(); final er = (srcR - qr) * ditheringStrength; final eg = (srcG - qg) * ditheringStrength; final eb = (srcB - qb) * ditheringStrength; if (x + 1 < gridW) { final n = idx + 1; errorR[n] += er * (7 / 16); errorG[n] += eg * (7 / 16); errorB[n] += eb * (7 / 16); } if (y + 1 < gridH) { if (x > 0) { final n = idx + gridW - 1; errorR[n] += er * (3 / 16); errorG[n] += eg * (3 / 16); errorB[n] += eb * (3 / 16); } final nDown = idx + gridW; errorR[nDown] += er * (5 / 16); errorG[nDown] += eg * (5 / 16); errorB[nDown] += eb * (5 / 16); if (x + 1 < gridW) { final n = idx + gridW + 1; errorR[n] += er * (1 / 16); errorG[n] += eg * (1 / 16); errorB[n] += eb * (1 / 16); } } } } } final preview = img.Image(width: gridW, height: gridH); for (int y = 0; y < gridH; y++) { for (int x = 0; x < gridW; x++) { final idx = assignments[y * gridW + x]; final argb = paletteValues[idx]; preview.setPixelRgba( x, y, (argb >> 16) & 0xFF, (argb >> 8) & 0xFF, argb & 0xFF, 255, ); } } return { 'width': gridW, 'height': gridH, 'assignments': assignments, 'counts': counts, 'palette': paletteRaw, 'previewPng': Uint8List.fromList(img.encodePng(preview, level: 1)), }; } Map _extractCapFromCenterIsolate(Uint8List sourceBytes) { final decoded = img.decodeImage(sourceBytes); if (decoded == null) { return { 'color': Colors.orange.toARGB32(), 'previewPng': Uint8List.fromList( img.encodePng(img.Image(width: 1, height: 1), level: 1), ), }; } final cropSize = math.min(decoded.width, decoded.height); final startX = (decoded.width - cropSize) ~/ 2; final startY = (decoded.height - cropSize) ~/ 2; final centered = img.copyCrop( decoded, x: startX, y: startY, width: cropSize, height: cropSize, ); final analysisSize = centered.width > 420 ? img.copyResize(centered, width: 420, height: 420) : centered; final cx = analysisSize.width / 2; final cy = analysisSize.height / 2; final radius = math.min(analysisSize.width, analysisSize.height) * 0.30; final r2 = radius * radius; final buckets = {}; double sumR = 0; double sumG = 0; double sumB = 0; int included = 0; for (int y = 0; y < analysisSize.height; y++) { final dy = y - cy; for (int x = 0; x < analysisSize.width; x++) { final dx = x - cx; if ((dx * dx) + (dy * dy) > r2) continue; final p = analysisSize.getPixel(x, y); final r = p.r.toInt(); final g = p.g.toInt(); final b = p.b.toInt(); 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); } } int resultArgb; if (included == 0) { resultArgb = const Color(0xFFFF9800).toARGB32(); } else { _RgbBucket? dominant; for (final bucket in buckets.values) { if (dominant == null || bucket.count > dominant.count) dominant = bucket; } if (dominant == null || dominant.count < included * 0.08) { resultArgb = Color.fromARGB( 255, (sumR / included).round(), (sumG / included).round(), (sumB / included).round(), ).toARGB32(); } else { resultArgb = Color.fromARGB( 255, (dominant.r / dominant.count).round(), (dominant.g / dominant.count).round(), (dominant.b / dominant.count).round(), ).toARGB32(); } } return { 'color': resultArgb, 'previewPng': Uint8List.fromList(img.encodePng(centered, level: 1)), }; } class _RgbBucket { int count = 0; int r = 0; int g = 0; int b = 0; void add(int nr, int ng, int nb) { count++; r += nr; g += ng; b += nb; } } class _Candidate { final int index; final double distance; const _Candidate({required this.index, required this.distance}); } double _mix(double a, double b, double t) => a + (b - a) * t; List _argbToLab(int argb) { final rgb = argb & 0x00FFFFFF; return _rgbToLab(rgb); } List _rgbToLab(int rgb) { double r = ((rgb >> 16) & 0xFF) / 255.0; double g = ((rgb >> 8) & 0xFF) / 255.0; double b = (rgb & 0xFF) / 255.0; r = r <= 0.04045 ? r / 12.92 : math.pow((r + 0.055) / 1.055, 2.4).toDouble(); g = g <= 0.04045 ? g / 12.92 : math.pow((g + 0.055) / 1.055, 2.4).toDouble(); b = b <= 0.04045 ? b / 12.92 : math.pow((b + 0.055) / 1.055, 2.4).toDouble(); final x = (r * 0.4124 + g * 0.3576 + b * 0.1805) / 0.95047; final y = (r * 0.2126 + g * 0.7152 + b * 0.0722) / 1.00000; final z = (r * 0.0193 + g * 0.1192 + b * 0.9505) / 1.08883; double f(double t) => t > 0.008856 ? math.pow(t, 1.0 / 3.0).toDouble() : (7.787 * t) + (16 / 116); final fx = f(x); final fy = f(y); final fz = f(z); final l = (116 * fy) - 16; final a = 500 * (fx - fy); final bStar = 200 * (fy - fz); return [l, a, bStar]; } double _deltaE76(List lab1, List lab2) { final dl = lab1[0] - lab2[0]; final da = lab1[1] - lab2[1]; final db = lab1[2] - lab2[2]; return math.sqrt(dl * dl + da * da + db * db); }