Files
korken_mosaic/lib/main.dart

1072 lines
34 KiB
Dart

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<MosaicHomePage> createState() => _MosaicHomePageState();
}
class _MosaicHomePageState extends State<MosaicHomePage> {
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<CapColor> _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<void> _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<void> _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<void>(
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<void>(
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<void> _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 = <String, dynamic>{
'source': _sourceImageBytes!,
'useCapSize': _useCapSize,
'gridW': gridW,
'gridH': gridH,
'capSize': capSize,
'fidelityStructure': _fidelityStructure,
'ditheringStrength': _ditheringStrength,
'edgeEmphasis': _edgeEmphasis,
'colorVariation': _colorVariation,
'palette': _palette
.map((p) =>
<String, dynamic>{'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<int>();
final counts = <CapColor, int>{};
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<int>(),
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<bool>(
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<StylePreset>(
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<double> 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<int> assignments;
final List<CapColor> palette;
final Map<CapColor, int> counts;
final List<MapEntry<CapColor, int>> 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<String, dynamic> _generateMosaicIsolate(Map<String, dynamic> 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<Map>();
final decoded = img.decodeImage(source);
if (decoded == null) {
return <String, dynamic>{
'width': 1,
'height': 1,
'assignments': <int>[0],
'counts': <int>[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<double>.filled(pixelCount, 0);
final workingG = List<double>.filled(pixelCount, 0);
final workingB = List<double>.filled(pixelCount, 0);
final luminance = List<double>.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<double>.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<double>.filled(pixelCount, 0);
final blurredG = List<double>.filled(pixelCount, 0);
final blurredB = List<double>.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<int>.filled(pixelCount, 0);
final counts = List<int>.filled(paletteValues.length, 0);
final errorR = List<double>.filled(pixelCount, 0);
final errorG = List<double>.filled(pixelCount, 0);
final errorB = List<double>.filled(pixelCount, 0);
final labCache = <int, List<double>>{};
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 <String, dynamic>{
'width': gridW,
'height': gridH,
'assignments': assignments,
'counts': counts,
'palette': paletteRaw,
'previewPng': Uint8List.fromList(img.encodePng(preview, level: 1)),
};
}
Map<String, dynamic> _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 = <int, _RgbBucket>{};
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<double> _argbToLab(int argb) {
final rgb = argb & 0x00FFFFFF;
return _rgbToLab(rgb);
}
List<double> _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<double> lab1, List<double> 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);
}