1756 lines
59 KiB
Dart
1756 lines
59 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
import 'dart:math' as math;
|
|
|
|
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 }
|
|
|
|
enum ColorExtractionMode { dominant, average }
|
|
|
|
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;
|
|
ColorExtractionMode _colorExtractionMode = ColorExtractionMode.dominant;
|
|
|
|
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,
|
|
preferredCameraDevice: CameraDevice.rear,
|
|
imageQuality: 95,
|
|
maxWidth: 1800,
|
|
);
|
|
if (captured == null) return;
|
|
|
|
final bytes = await captured.readAsBytes();
|
|
final detected = await compute(_extractCapFromPhotoIsolate, bytes);
|
|
if (!mounted) return;
|
|
|
|
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;
|
|
_photoCapNameCtrl.text = 'Fotografierter Deckel';
|
|
_photoCapHexCtrl.text = _colorToHex(selected);
|
|
|
|
showDialog<void>(
|
|
context: context,
|
|
builder: (ctx) {
|
|
return StatefulBuilder(
|
|
builder: (ctx, setDialogState) {
|
|
Future<void> recalculate() async {
|
|
final adjusted = await compute(
|
|
_extractCapFromAdjustedCircleIsolate,
|
|
<String, dynamic>{
|
|
'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(
|
|
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(
|
|
usedFallback
|
|
? 'Kreiserkennung per Fallback. Bei Bedarf Kreis manuell korrigieren.'
|
|
: 'Kreis automatisch erkannt. Optional unten feinjustieren.',
|
|
style: Theme.of(context).textTheme.bodySmall,
|
|
),
|
|
const SizedBox(height: 8),
|
|
SegmentedButton<ColorExtractionMode>(
|
|
segments: const [
|
|
ButtonSegment(
|
|
value: ColorExtractionMode.dominant,
|
|
label: Text('Dominant (robust)'),
|
|
),
|
|
ButtonSegment(
|
|
value: ColorExtractionMode.average,
|
|
label: Text('Gewichteter Mittelwert'),
|
|
),
|
|
],
|
|
selected: {mode},
|
|
onSelectionChanged: (selection) {
|
|
mode = selection.first;
|
|
selected = mode == ColorExtractionMode.dominant
|
|
? dominantColor
|
|
: averageColor;
|
|
_photoCapHexCtrl.text = _colorToHex(selected);
|
|
setDialogState(() {});
|
|
},
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
'Robuste Analyse: Schatten/Outlier werden reduziert, nur geeignete Pixel im Kreis werden gewichtet.',
|
|
style: Theme.of(context).textTheme.bodySmall,
|
|
),
|
|
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(
|
|
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);
|
|
_colorExtractionMode = mode;
|
|
entry.imagePath =
|
|
await _saveThumbnail(previewBytes, entry.id);
|
|
_catalog.add(entry);
|
|
await _persistCatalog();
|
|
if (!mounted) return;
|
|
setState(() {});
|
|
if (!ctx.mounted) return;
|
|
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(() {});
|
|
if (!ctx.mounted) return;
|
|
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(() {});
|
|
if (!ctx.mounted) return;
|
|
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> _extractCapFromPhotoIsolate(Uint8List sourceBytes) {
|
|
final decoded = img.decodeImage(sourceBytes);
|
|
if (decoded == null) {
|
|
return {
|
|
'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)),
|
|
};
|
|
}
|
|
|
|
const int analysisMaxSize = 480;
|
|
final double 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 detectedMaybe = _detectCapCircle(analysis);
|
|
final detected =
|
|
detectedMaybe ?? _fallbackCapCircle(analysis.width, analysis.height);
|
|
final usedFallback = detectedMaybe == null;
|
|
|
|
final upscaledCircle = _DetectedCircle(
|
|
cx: detected.cx / scale,
|
|
cy: detected.cy / scale,
|
|
r: detected.r / scale,
|
|
);
|
|
|
|
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<String, dynamic> _extractCapFromAdjustedCircleIsolate(
|
|
Map<String, dynamic> 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)
|
|
.round());
|
|
final previewScale = preview.width / analysis.width;
|
|
_drawCircle(preview, detected.cx * previewScale, detected.cy * previewScale,
|
|
detected.r * previewScale);
|
|
return preview;
|
|
}
|
|
|
|
_DetectedCircle? _detectCapCircle(img.Image image) {
|
|
final width = image.width;
|
|
final height = image.height;
|
|
if (width < 40 || height < 40) return null;
|
|
|
|
final gray = List<double>.filled(width * height, 0);
|
|
for (int y = 0; y < height; y++) {
|
|
for (int x = 0; x < width; x++) {
|
|
final p = image.getPixel(x, y);
|
|
gray[y * width + x] = 0.2126 * p.r + 0.7152 * p.g + 0.0722 * p.b;
|
|
}
|
|
}
|
|
|
|
final edge = List<double>.filled(width * height, 0);
|
|
double edgeMax = 1;
|
|
for (int y = 1; y < height - 1; y++) {
|
|
for (int x = 1; x < width - 1; x++) {
|
|
final gx = gray[y * width + (x + 1)] - gray[y * width + (x - 1)];
|
|
final gy = gray[(y + 1) * width + x] - gray[(y - 1) * width + x];
|
|
final m = math.sqrt(gx * gx + gy * gy);
|
|
edge[y * width + x] = m;
|
|
if (m > edgeMax) edgeMax = m;
|
|
}
|
|
}
|
|
|
|
final minRadius = (math.min(width, height) * 0.14).round();
|
|
final maxRadius = (math.min(width, height) * 0.48).round();
|
|
if (maxRadius <= minRadius) return null;
|
|
|
|
_DetectedCircle? best;
|
|
double bestScore = 0;
|
|
|
|
for (int r = minRadius; r <= maxRadius; r += 4) {
|
|
final centerStep = math.max(4, r ~/ 7);
|
|
final samples = math.max(24, (2 * math.pi * r / 5).round());
|
|
|
|
for (int cy = r; cy < height - r; cy += centerStep) {
|
|
for (int cx = r; cx < width - r; cx += centerStep) {
|
|
double ringScore = 0;
|
|
int valid = 0;
|
|
for (int i = 0; i < samples; i++) {
|
|
final t = (2 * math.pi * i) / samples;
|
|
final x = (cx + r * math.cos(t)).round();
|
|
final y = (cy + r * math.sin(t)).round();
|
|
if (x < 1 || x >= width - 1 || y < 1 || y >= height - 1) continue;
|
|
ringScore += edge[y * width + x] / edgeMax;
|
|
valid++;
|
|
}
|
|
if (valid < samples * 0.7) continue;
|
|
|
|
final normalizedRing = ringScore / valid;
|
|
if (normalizedRing < 0.22) continue;
|
|
|
|
final centerIdx = cy * width + cx;
|
|
final centerPenalty = 0.85 + (gray[centerIdx] / 255.0) * 0.15;
|
|
final score = normalizedRing * centerPenalty;
|
|
|
|
if (score > bestScore) {
|
|
bestScore = score;
|
|
best = _DetectedCircle(
|
|
cx: cx.toDouble(), cy: cy.toDouble(), r: r.toDouble());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return bestScore >= 0.25 ? best : null;
|
|
}
|
|
|
|
_DetectedCircle _fallbackCapCircle(int width, int height) {
|
|
return _DetectedCircle(
|
|
cx: width / 2,
|
|
cy: height / 2,
|
|
r: math.min(width, height) * 0.28,
|
|
);
|
|
}
|
|
|
|
_CapColorStats _sampleCapColors(img.Image image, _DetectedCircle circle) {
|
|
final samples = <_ColorSample>[];
|
|
|
|
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());
|
|
final minY = math.max(0, (circle.cy - insetRadius).floor());
|
|
final maxY = math.min(image.height - 1, (circle.cy + insetRadius).ceil());
|
|
|
|
for (int y = minY; y <= maxY; y++) {
|
|
final dy = y - circle.cy;
|
|
for (int x = minX; x <= maxX; x++) {
|
|
final dx = x - circle.cx;
|
|
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;
|
|
|
|
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 (samples.isEmpty) {
|
|
final fallback = const Color(0xFFFF9800).toARGB32();
|
|
return _CapColorStats(averageArgb: fallback, dominantArgb: fallback);
|
|
}
|
|
|
|
final lumValues = samples.map((s) => s.luminance).toList()..sort();
|
|
final lowLum = _percentile(lumValues, 0.18);
|
|
final highLum = _percentile(lumValues, 0.98);
|
|
|
|
final filtered = samples.where((s) {
|
|
if (s.luminance < lowLum || s.luminance > highLum) return false;
|
|
if (s.value < 0.18) return false;
|
|
return true;
|
|
}).toList();
|
|
|
|
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) {
|
|
final x = cx.round();
|
|
final y = cy.round();
|
|
final radius = r.round();
|
|
for (int i = 0; i < 3; i++) {
|
|
img.drawCircle(
|
|
image,
|
|
x: x,
|
|
y: y,
|
|
radius: math.max(2, radius - i),
|
|
color: img.ColorRgb8(255, 255, 255),
|
|
);
|
|
}
|
|
img.drawCircle(
|
|
image,
|
|
x: x,
|
|
y: y,
|
|
radius: math.max(2, radius - 3),
|
|
color: img.ColorRgb8(0, 140, 255),
|
|
);
|
|
}
|
|
|
|
class _DetectedCircle {
|
|
final double cx;
|
|
final double cy;
|
|
final double r;
|
|
|
|
const _DetectedCircle({required this.cx, required this.cy, required this.r});
|
|
}
|
|
|
|
class _CapColorStats {
|
|
final int averageArgb;
|
|
final int dominantArgb;
|
|
|
|
const _CapColorStats({required this.averageArgb, required this.dominantArgb});
|
|
}
|
|
|
|
class _ColorSample {
|
|
final int r;
|
|
final int g;
|
|
final int b;
|
|
final double saturation;
|
|
final double value;
|
|
final double luminance;
|
|
final double radialWeight;
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
class _Candidate {
|
|
final int index;
|
|
final double distance;
|
|
|
|
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 = <int, _WeightedRgb>{};
|
|
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<double> _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<double> 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<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);
|
|
}
|