1341 lines
45 KiB
Dart
1341 lines
45 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
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';
|
|
import 'package:path_provider/path_provider.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 }
|
|
|
|
enum CatalogViewMode { list, grid }
|
|
|
|
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;
|
|
bool _isCatalogLoaded = false;
|
|
CatalogViewMode _catalogViewMode = CatalogViewMode.grid;
|
|
|
|
double _fidelityStructure = 0.5;
|
|
double _ditheringStrength = 0.35;
|
|
double _edgeEmphasis = 0.4;
|
|
double _colorVariation = 0.3;
|
|
StylePreset _selectedPreset = StylePreset.ausgewogen;
|
|
|
|
List<CapCatalogEntry> _catalog = [];
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_gridWidthCtrl.addListener(_scheduleRegenerate);
|
|
_gridHeightCtrl.addListener(_scheduleRegenerate);
|
|
_capSizeCtrl.addListener(_scheduleRegenerate);
|
|
_loadCatalog();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_debounceTimer?.cancel();
|
|
_gridWidthCtrl.dispose();
|
|
_gridHeightCtrl.dispose();
|
|
_capSizeCtrl.dispose();
|
|
_photoCapNameCtrl.dispose();
|
|
_photoCapHexCtrl.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<File> _catalogFile() async {
|
|
final docs = await getApplicationDocumentsDirectory();
|
|
return File('${docs.path}/cap_catalog.json');
|
|
}
|
|
|
|
Future<String> _saveThumbnail(Uint8List bytes, String id) async {
|
|
final docs = await getApplicationDocumentsDirectory();
|
|
final dir = Directory('${docs.path}/cap_thumbs');
|
|
await dir.create(recursive: true);
|
|
final file = File('${dir.path}/$id.png');
|
|
await file.writeAsBytes(bytes, flush: true);
|
|
return file.path;
|
|
}
|
|
|
|
Future<void> _loadCatalog() async {
|
|
final defaults = [
|
|
CapCatalogEntry.newEntry(name: 'White', color: const Color(0xFFF2F2F2)),
|
|
CapCatalogEntry.newEntry(name: 'Black', color: const Color(0xFF222222)),
|
|
CapCatalogEntry.newEntry(name: 'Red', color: const Color(0xFFD84343)),
|
|
CapCatalogEntry.newEntry(name: 'Blue', color: const Color(0xFF3F6FD8)),
|
|
CapCatalogEntry.newEntry(name: 'Green', color: const Color(0xFF4FAE63)),
|
|
];
|
|
|
|
try {
|
|
final file = await _catalogFile();
|
|
if (await file.exists()) {
|
|
final jsonRaw = jsonDecode(await file.readAsString()) as List<dynamic>;
|
|
_catalog = jsonRaw
|
|
.map((e) => CapCatalogEntry.fromJson(e as Map<String, dynamic>))
|
|
.toList();
|
|
}
|
|
if (_catalog.isEmpty) {
|
|
_catalog = defaults;
|
|
await _persistCatalog();
|
|
}
|
|
} catch (_) {
|
|
_catalog = defaults;
|
|
}
|
|
|
|
if (!mounted) return;
|
|
setState(() => _isCatalogLoaded = true);
|
|
}
|
|
|
|
Future<void> _persistCatalog() async {
|
|
final file = await _catalogFile();
|
|
final jsonData = jsonEncode(_catalog.map((e) => e.toJson()).toList());
|
|
await file.writeAsString(jsonData, flush: true);
|
|
}
|
|
|
|
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: () async {
|
|
final name = _photoCapNameCtrl.text.trim();
|
|
if (name.isEmpty) return;
|
|
final parsed = _parseHex(_photoCapHexCtrl.text);
|
|
final entry = CapCatalogEntry.newEntry(
|
|
name: name, color: parsed ?? selected);
|
|
entry.imagePath =
|
|
await _saveThumbnail(previewBytes, entry.id);
|
|
_catalog.add(entry);
|
|
await _persistCatalog();
|
|
if (!mounted) return;
|
|
setState(() {});
|
|
Navigator.pop(ctx);
|
|
_scheduleRegenerate();
|
|
},
|
|
child: const Text('Zum Katalog 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();
|
|
}
|
|
|
|
Future<void> _addCapDialog() async {
|
|
Color selected = Colors.orange;
|
|
final nameCtrl = TextEditingController();
|
|
final hexCtrl = TextEditingController(text: _colorToHex(selected));
|
|
String? imagePath;
|
|
|
|
await showDialog<void>(
|
|
context: context,
|
|
builder: (ctx) {
|
|
return StatefulBuilder(
|
|
builder: (ctx, setDialogState) {
|
|
return AlertDialog(
|
|
title: const Text('Deckel manuell hinzufügen'),
|
|
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)
|
|
setDialogState(() => selected = parsed);
|
|
},
|
|
),
|
|
const SizedBox(height: 8),
|
|
OutlinedButton.icon(
|
|
onPressed: () async {
|
|
final picked = await _picker.pickImage(
|
|
source: ImageSource.gallery,
|
|
maxWidth: 1200,
|
|
imageQuality: 90);
|
|
if (picked == null) return;
|
|
imagePath = picked.path;
|
|
setDialogState(() {});
|
|
},
|
|
icon: const Icon(Icons.image_outlined),
|
|
label: Text(imagePath == null
|
|
? 'Optionales Foto wählen'
|
|
: 'Foto gewählt ✅'),
|
|
),
|
|
const SizedBox(height: 12),
|
|
ColorPicker(
|
|
pickerColor: selected,
|
|
onColorChanged: (c) {
|
|
selected = c;
|
|
hexCtrl.text = _colorToHex(c);
|
|
setDialogState(() {});
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(ctx),
|
|
child: const Text('Cancel')),
|
|
FilledButton(
|
|
onPressed: () async {
|
|
final name = nameCtrl.text.trim();
|
|
if (name.isEmpty) return;
|
|
final entry = CapCatalogEntry.newEntry(
|
|
name: name,
|
|
color: _parseHex(hexCtrl.text) ?? selected,
|
|
imagePath: imagePath);
|
|
_catalog.add(entry);
|
|
await _persistCatalog();
|
|
if (!mounted) return;
|
|
setState(() {});
|
|
Navigator.pop(ctx);
|
|
_scheduleRegenerate();
|
|
},
|
|
child: const Text('Add'),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Future<void> _editEntry(CapCatalogEntry entry) async {
|
|
Color selected = entry.color;
|
|
final nameCtrl = TextEditingController(text: entry.name);
|
|
final hexCtrl = TextEditingController(text: _colorToHex(entry.color));
|
|
|
|
await showDialog<void>(
|
|
context: context,
|
|
builder: (ctx) {
|
|
return AlertDialog(
|
|
title: const Text('Deckel bearbeiten'),
|
|
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('Abbrechen')),
|
|
FilledButton(
|
|
onPressed: () async {
|
|
entry.name = nameCtrl.text.trim().isEmpty
|
|
? entry.name
|
|
: nameCtrl.text.trim();
|
|
entry.colorValue =
|
|
(_parseHex(hexCtrl.text) ?? selected).toARGB32();
|
|
await _persistCatalog();
|
|
if (!mounted) return;
|
|
setState(() {});
|
|
Navigator.pop(ctx);
|
|
_scheduleRegenerate();
|
|
},
|
|
child: const Text('Speichern'),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Future<void> _deleteEntry(CapCatalogEntry entry) async {
|
|
if (_catalog.length <= 1) return;
|
|
_catalog.removeWhere((e) => e.id == entry.id);
|
|
if (entry.imagePath != null) {
|
|
final f = File(entry.imagePath!);
|
|
if (await f.exists()) {
|
|
await f.delete();
|
|
}
|
|
}
|
|
await _persistCatalog();
|
|
if (!mounted) return;
|
|
setState(() {});
|
|
_scheduleRegenerate();
|
|
}
|
|
|
|
Future<void> _generate() async {
|
|
if (_sourceImageBytes == null || _catalog.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': _catalog
|
|
.map((p) => <String, dynamic>{'name': p.name, 'value': p.colorValue})
|
|
.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: !_isCatalogLoaded
|
|
? const Center(child: CircularProgressIndicator())
|
|
: 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 Catalog',
|
|
style: TextStyle(
|
|
fontSize: 18, fontWeight: FontWeight.bold))),
|
|
IconButton(
|
|
onPressed: () => setState(
|
|
() => _catalogViewMode = CatalogViewMode.list),
|
|
icon: Icon(Icons.view_list,
|
|
color: _catalogViewMode == CatalogViewMode.list
|
|
? Theme.of(context).colorScheme.primary
|
|
: null),
|
|
tooltip: 'Listenansicht',
|
|
),
|
|
IconButton(
|
|
onPressed: () => setState(
|
|
() => _catalogViewMode = CatalogViewMode.grid),
|
|
icon: Icon(Icons.grid_view,
|
|
color: _catalogViewMode == CatalogViewMode.grid
|
|
? Theme.of(context).colorScheme.primary
|
|
: null),
|
|
tooltip: 'Rasteransicht',
|
|
),
|
|
OutlinedButton.icon(
|
|
onPressed: _captureCapPhoto,
|
|
icon: const Icon(Icons.photo_camera_outlined),
|
|
label: const Text('Foto')),
|
|
const SizedBox(width: 8),
|
|
IconButton(
|
|
onPressed: _addCapDialog,
|
|
icon: const Icon(Icons.add_circle_outline),
|
|
tooltip: 'Manuell hinzufügen'),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
_buildCatalogView(),
|
|
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'),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildCatalogView() {
|
|
if (_catalog.isEmpty) return const Text('Noch keine Deckel im Katalog');
|
|
|
|
if (_catalogViewMode == CatalogViewMode.list) {
|
|
return Column(
|
|
children: _catalog
|
|
.map(
|
|
(entry) => Card(
|
|
child: ListTile(
|
|
leading: _CapThumb(entry: entry),
|
|
title: Text(entry.name),
|
|
subtitle: Text(_colorToHex(entry.color)),
|
|
trailing: Wrap(
|
|
spacing: 4,
|
|
children: [
|
|
CircleAvatar(radius: 10, backgroundColor: entry.color),
|
|
IconButton(
|
|
onPressed: () => _editEntry(entry),
|
|
icon: const Icon(Icons.edit_outlined)),
|
|
IconButton(
|
|
onPressed: _catalog.length <= 1
|
|
? null
|
|
: () => _deleteEntry(entry),
|
|
icon: const Icon(Icons.delete_outline),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
)
|
|
.toList(),
|
|
);
|
|
}
|
|
|
|
return GridView.builder(
|
|
shrinkWrap: true,
|
|
physics: const NeverScrollableScrollPhysics(),
|
|
itemCount: _catalog.length,
|
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
|
crossAxisCount: 2,
|
|
childAspectRatio: 1.2,
|
|
mainAxisSpacing: 8,
|
|
crossAxisSpacing: 8,
|
|
),
|
|
itemBuilder: (context, index) {
|
|
final entry = _catalog[index];
|
|
return Card(
|
|
clipBehavior: Clip.antiAlias,
|
|
child: InkWell(
|
|
onTap: () => _editEntry(entry),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(8),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Expanded(child: _CapThumb(entry: entry, large: true)),
|
|
const SizedBox(height: 6),
|
|
Text(entry.name,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(fontWeight: FontWeight.w600)),
|
|
Row(
|
|
children: [
|
|
CircleAvatar(radius: 8, backgroundColor: entry.color),
|
|
const SizedBox(width: 6),
|
|
Text(_colorToHex(entry.color),
|
|
style: Theme.of(context).textTheme.bodySmall),
|
|
const Spacer(),
|
|
IconButton(
|
|
visualDensity: VisualDensity.compact,
|
|
onPressed: _catalog.length <= 1
|
|
? null
|
|
: () => _deleteEntry(entry),
|
|
icon: const Icon(Icons.delete_outline),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
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 _CapThumb extends StatelessWidget {
|
|
final CapCatalogEntry entry;
|
|
final bool large;
|
|
|
|
const _CapThumb({required this.entry, this.large = false});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final size = large ? double.infinity : 42.0;
|
|
final radius = BorderRadius.circular(large ? 12 : 8);
|
|
|
|
if (entry.imagePath != null && File(entry.imagePath!).existsSync()) {
|
|
return ClipRRect(
|
|
borderRadius: radius,
|
|
child: Image.file(File(entry.imagePath!),
|
|
width: size, height: size, fit: BoxFit.cover),
|
|
);
|
|
}
|
|
|
|
return Container(
|
|
width: size,
|
|
height: size,
|
|
decoration: BoxDecoration(
|
|
color: entry.color,
|
|
borderRadius: radius,
|
|
border: Border.all(color: Colors.black12)),
|
|
);
|
|
}
|
|
}
|
|
|
|
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 CapCatalogEntry {
|
|
final String id;
|
|
String name;
|
|
int colorValue;
|
|
String? imagePath;
|
|
|
|
CapCatalogEntry(
|
|
{required this.id,
|
|
required this.name,
|
|
required this.colorValue,
|
|
this.imagePath});
|
|
|
|
factory CapCatalogEntry.newEntry(
|
|
{required String name, required Color color, String? imagePath}) {
|
|
return CapCatalogEntry(
|
|
id: DateTime.now().microsecondsSinceEpoch.toString(),
|
|
name: name,
|
|
colorValue: color.toARGB32(),
|
|
imagePath: imagePath,
|
|
);
|
|
}
|
|
|
|
Color get color => Color(colorValue);
|
|
|
|
Map<String, dynamic> toJson() => {
|
|
'id': id,
|
|
'name': name,
|
|
'colorValue': colorValue,
|
|
'imagePath': imagePath,
|
|
};
|
|
|
|
factory CapCatalogEntry.fromJson(Map<String, dynamic> json) =>
|
|
CapCatalogEntry(
|
|
id: json['id'] as String,
|
|
name: json['name'] as String,
|
|
colorValue: json['colorValue'] as int,
|
|
imagePath: json['imagePath'] as String?,
|
|
);
|
|
}
|
|
|
|
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);
|
|
}
|