Files
korken_mosaic/lib/main.dart

2310 lines
73 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 }
enum HomeSection { mosaic, catalog }
class MosaicHomePage extends StatefulWidget {
const MosaicHomePage({super.key});
@override
State<MosaicHomePage> createState() => _MosaicHomePageState();
}
class _MosaicHomePageState extends State<MosaicHomePage>
with WidgetsBindingObserver {
final ImagePicker _picker = ImagePicker();
// Capture review state lives in a dedicated full-screen route.
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;
HomeSection _activeSection = HomeSection.mosaic;
CatalogViewMode _catalogViewMode = CatalogViewMode.grid;
ColorExtractionMode _colorExtractionMode = ColorExtractionMode.dominant;
bool _isCaptureFlowInProgress = false;
bool _isRecoveringCapture = false;
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();
WidgetsBinding.instance.addObserver(this);
_gridWidthCtrl.addListener(_scheduleRegenerate);
_gridHeightCtrl.addListener(_scheduleRegenerate);
_capSizeCtrl.addListener(_scheduleRegenerate);
_loadCatalog();
WidgetsBinding.instance.addPostFrameCallback((_) {
_recoverCaptureOnResumeOrStart();
});
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
_recoverCaptureOnResumeOrStart();
}
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_debounceTimer?.cancel();
_gridWidthCtrl.dispose();
_gridHeightCtrl.dispose();
_capSizeCtrl.dispose();
// No extra capture controllers to 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<File> _pendingCaptureFile() async {
final docs = await getApplicationDocumentsDirectory();
return File('${docs.path}/pending_cap_capture.jpg');
}
Future<void> _setPendingCapture(Uint8List? bytes) async {
final file = await _pendingCaptureFile();
if (bytes == null) {
if (await file.exists()) {
await file.delete();
}
return;
}
await file.writeAsBytes(bytes, flush: true);
}
Future<Uint8List?> _readPendingCapture() async {
final file = await _pendingCaptureFile();
if (!await file.exists()) return null;
return file.readAsBytes();
}
Future<void> _recoverCaptureOnResumeOrStart() async {
if (_isRecoveringCapture || _isCaptureFlowInProgress || !mounted) return;
_isRecoveringCapture = true;
try {
Uint8List? recovered;
try {
final lost = await _picker.retrieveLostData();
if (!lost.isEmpty) {
final file = lost.file ??
(lost.files?.isNotEmpty == true ? lost.files!.first : null);
if (file != null) {
recovered = await file.readAsBytes();
await _setPendingCapture(recovered);
}
}
} catch (e, st) {
debugPrint('[cap-photo] retrieveLostData failed: $e\n$st');
}
recovered ??= await _readPendingCapture();
if (recovered == null || !mounted) return;
await _openCaptureReviewFlow(recovered, recoveredFromPending: true);
} finally {
_isRecoveringCapture = false;
}
}
Future<void> _captureCapPhoto() async {
if (_isCaptureFlowInProgress) return;
setState(() => _isCaptureFlowInProgress = true);
try {
final XFile? captured = await _picker.pickImage(
source: ImageSource.camera,
preferredCameraDevice: CameraDevice.rear,
imageQuality: 95,
maxWidth: 1800,
);
if (captured == null) {
debugPrint('[cap-photo] Capture cancelled by user.');
await _setPendingCapture(null);
return;
}
Uint8List bytes;
try {
bytes = await captured.readAsBytes();
} catch (e, st) {
debugPrint('[cap-photo] Failed to read captured bytes: $e\n$st');
final fallbackImage = img.Image(width: 1, height: 1)
..setPixelRgb(0, 0, 255, 152, 0);
bytes = Uint8List.fromList(img.encodePng(fallbackImage, level: 1));
}
await _setPendingCapture(bytes);
await _openCaptureReviewFlow(bytes);
} finally {
if (mounted) {
setState(() => _isCaptureFlowInProgress = false);
} else {
_isCaptureFlowInProgress = false;
}
}
}
Future<void> _openCaptureReviewFlow(
Uint8List bytes, {
bool recoveredFromPending = false,
}) async {
Map<String, dynamic> detected;
String? detectionWarning;
try {
detected = await compute(_extractCapFromPhotoIsolate, bytes);
} catch (e, st) {
debugPrint('[cap-photo] Detection isolate failed: $e\n$st');
detectionWarning =
'⚠️ Farberkennung ist fehlgeschlagen. Fallback-Farbe wird verwendet; bitte prüfen/korrigieren.';
final decoded = img.decodeImage(bytes);
final imageW = (decoded?.width ?? 1).toDouble();
final imageH = (decoded?.height ?? 1).toDouble();
final fallbackColor = Colors.orange.toARGB32();
detected = {
'dominantColor': fallbackColor,
'averageColor': fallbackColor,
'usedFallback': true,
'circleX': imageW / 2,
'circleY': imageH / 2,
'circleR': math.min(imageW, imageH) * 0.28,
'imageW': imageW,
'imageH': imageH,
'previewPng': bytes,
};
}
if (!mounted) return;
final reviewResult =
await Navigator.of(context).push<_CapPhotoReviewResult>(
MaterialPageRoute(
fullscreenDialog: true,
builder: (_) => _CapPhotoReviewPage(
imageBytes: bytes,
detected: detected,
initialMode: _colorExtractionMode,
initialName: 'Fotografierter Deckel',
initialWarning: detectionWarning,
),
),
);
if (reviewResult == null) {
await _setPendingCapture(null);
return;
}
_colorExtractionMode = reviewResult.mode;
final entry = CapCatalogEntry.newEntry(
name: reviewResult.name,
color: reviewResult.color,
);
entry.imagePath = await _saveThumbnail(reviewResult.previewBytes, entry.id);
_catalog.add(entry);
await _persistCatalog();
await _setPendingCapture(null);
if (!mounted) return;
setState(() {});
_scheduleRegenerate();
}
// Circle clamping helper is used by the dedicated review route.
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));
String? imagePath = entry.imagePath;
await showDialog<void>(
context: context,
builder: (ctx) {
return StatefulBuilder(builder: (ctx, setDialogState) {
return AlertDialog(
title: const Text('Deckel bearbeiten'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (imagePath != null && File(imagePath!).existsSync())
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.file(
File(imagePath!),
height: 160,
fit: BoxFit.cover,
),
)
else
Container(
height: 160,
alignment: Alignment.center,
decoration: BoxDecoration(
color:
Theme.of(ctx).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
),
child: const Icon(Icons.photo_outlined, size: 40),
),
const SizedBox(height: 8),
Row(
children: [
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: const Text('Foto ändern'),
),
const SizedBox(width: 8),
if (imagePath != null)
TextButton(
onPressed: () {
imagePath = null;
setDialogState(() {});
},
child: const Text('Foto entfernen'),
),
],
),
const SizedBox(height: 8),
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;
setDialogState(() {});
}
},
),
const SizedBox(height: 8),
Row(
children: [
CircleAvatar(radius: 14, backgroundColor: selected),
const SizedBox(width: 8),
Text(_colorToHex(selected)),
],
),
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('Abbrechen')),
FilledButton(
onPressed: () async {
entry.name = nameCtrl.text.trim().isEmpty
? entry.name
: nameCtrl.text.trim();
entry.colorValue =
(_parseHex(hexCtrl.text) ?? selected).toARGB32();
entry.imagePath = imagePath;
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: Text(_activeSection == HomeSection.mosaic
? 'Bottle-Cap Mosaic Prototype'
: 'Cap Catalog'),
),
floatingActionButton: _activeSection == HomeSection.mosaic
? 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'),
)
: null,
body: !_isCatalogLoaded
? const Center(child: CircularProgressIndicator())
: _activeSection == HomeSection.mosaic
? _buildMosaicScreen()
: _buildCatalogScreen(),
bottomNavigationBar: NavigationBar(
selectedIndex: _activeSection.index,
onDestinationSelected: (index) {
setState(() => _activeSection = HomeSection.values[index]);
},
destinations: const [
NavigationDestination(
icon: Icon(Icons.auto_awesome_outlined),
selectedIcon: Icon(Icons.auto_awesome),
label: 'Mosaic',
),
NavigationDestination(
icon: Icon(Icons.inventory_2_outlined),
selectedIcon: Icon(Icons.inventory_2),
label: 'Catalog',
),
],
),
);
}
Widget _buildMosaicScreen() {
return 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),
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 _buildCatalogScreen() {
return Padding(
padding: const EdgeInsets.all(12),
child: ListView(
children: [
Row(
children: [
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',
),
const Spacer(),
OutlinedButton.icon(
onPressed: _isCaptureFlowInProgress ? null : _captureCapPhoto,
icon: _isCaptureFlowInProgress
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.photo_camera_outlined),
label: Text(_isCaptureFlowInProgress ? 'Läuft…' : 'Foto')),
const SizedBox(width: 8),
IconButton(
onPressed: _addCapDialog,
icon: const Icon(Icons.add_circle_outline),
tooltip: 'Manuell hinzufügen'),
],
),
const SizedBox(height: 8),
_buildCatalogView(),
],
),
);
}
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(
onTap: () => _editEntry(entry),
leading: SizedBox(
width: 88,
child: Row(
children: [
_CapThumb(entry: entry),
const SizedBox(width: 8),
_ColorSwatch(color: entry.color),
],
),
),
title: Text(entry.name),
subtitle: Text(_colorToHex(entry.color)),
trailing: 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: [
Row(
children: [
Expanded(child: _CapThumb(entry: entry, large: true)),
const SizedBox(width: 8),
_ColorSwatch(color: entry.color, large: true),
],
),
const SizedBox(height: 6),
Text(entry.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w600)),
Row(
children: [
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 _CapPhotoReviewResult {
final String name;
final Color color;
final ColorExtractionMode mode;
final Uint8List previewBytes;
const _CapPhotoReviewResult({
required this.name,
required this.color,
required this.mode,
required this.previewBytes,
});
}
class _CapPhotoReviewPage extends StatefulWidget {
final Uint8List imageBytes;
final Map<String, dynamic> detected;
final ColorExtractionMode initialMode;
final String initialName;
final String? initialWarning;
const _CapPhotoReviewPage({
required this.imageBytes,
required this.detected,
required this.initialMode,
required this.initialName,
this.initialWarning,
});
@override
State<_CapPhotoReviewPage> createState() => _CapPhotoReviewPageState();
}
class _CapPhotoReviewPageState extends State<_CapPhotoReviewPage> {
late final TextEditingController _nameCtrl;
late final TextEditingController _hexCtrl;
late Color _dominantColor;
late Color _averageColor;
late Uint8List _previewBytes;
late bool _usedFallback;
late double _imageW;
late double _imageH;
late double _circleX;
late double _circleY;
late double _circleR;
late ColorExtractionMode _mode;
late Color _selected;
String? _warning;
Timer? _liveDebounce;
int _recalcToken = 0;
@override
void initState() {
super.initState();
final detected = widget.detected;
_dominantColor = Color(detected['dominantColor'] as int);
_averageColor = Color(detected['averageColor'] as int);
_previewBytes = detected['previewPng'] as Uint8List;
_usedFallback = detected['usedFallback'] as bool? ?? false;
_imageW = (detected['imageW'] as num).toDouble();
_imageH = (detected['imageH'] as num).toDouble();
_circleX = (detected['circleX'] as num).toDouble() / _imageW;
_circleY = (detected['circleY'] as num).toDouble() / _imageH;
_circleR =
(detected['circleR'] as num).toDouble() / math.min(_imageW, _imageH);
final clamped = _clampCircleNormalized(
imageW: _imageW,
imageH: _imageH,
circleX: _circleX,
circleY: _circleY,
circleR: _circleR,
);
_circleX = clamped.x;
_circleY = clamped.y;
_circleR = clamped.r;
_mode = widget.initialMode;
_selected =
_mode == ColorExtractionMode.dominant ? _dominantColor : _averageColor;
_nameCtrl = TextEditingController(text: widget.initialName);
_hexCtrl = TextEditingController(text: _colorToHexStatic(_selected));
_warning = widget.initialWarning;
}
@override
void dispose() {
_liveDebounce?.cancel();
_nameCtrl.dispose();
_hexCtrl.dispose();
super.dispose();
}
void _setAndClampCircle({double? x, double? y, double? r}) {
if (x != null) _circleX = x;
if (y != null) _circleY = y;
if (r != null) _circleR = r;
final clamped = _clampCircleNormalized(
imageW: _imageW,
imageH: _imageH,
circleX: _circleX,
circleY: _circleY,
circleR: _circleR,
);
_circleX = clamped.x;
_circleY = clamped.y;
_circleR = clamped.r;
}
void _scheduleLiveRecalculate() {
_liveDebounce?.cancel();
_liveDebounce = Timer(const Duration(milliseconds: 180), () {
_recalculate(updateUiBefore: false);
});
}
Future<void> _recalculate({bool updateUiBefore = true}) async {
final localToken = ++_recalcToken;
if (updateUiBefore && mounted) setState(() {});
try {
final adjusted = await compute(
_extractCapFromAdjustedCircleIsolate,
<String, dynamic>{
'sourceBytes': widget.imageBytes,
'circleX': _circleX,
'circleY': _circleY,
'circleR': _circleR,
},
);
if (localToken != _recalcToken) return;
_dominantColor = Color(adjusted['dominantColor'] as int);
_averageColor = Color(adjusted['averageColor'] as int);
_previewBytes = adjusted['previewPng'] as Uint8List;
_selected = _mode == ColorExtractionMode.dominant
? _dominantColor
: _averageColor;
_hexCtrl.text = _colorToHexStatic(_selected);
} catch (e, st) {
debugPrint('[cap-photo] Recalculate failed: $e\n$st');
if (localToken != _recalcToken) return;
_warning =
'⚠️ Aktualisierung fehlgeschlagen. Bitte Farbe manuell prüfen.';
const fallback = Colors.orange;
_dominantColor = fallback;
_averageColor = fallback;
_selected = fallback;
_hexCtrl.text = _colorToHexStatic(_selected);
}
if (mounted) setState(() {});
}
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);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Deckel prüfen')),
body: SafeArea(
child: ListView(
padding: const EdgeInsets.all(12),
children: [
_CircleAdjustOverlay(
imageBytes: widget.imageBytes,
imageWidth: _imageW,
imageHeight: _imageH,
circleX: _circleX,
circleY: _circleY,
circleR: _circleR,
onCircleChanged: (x, y, r) {
setState(() {
_setAndClampCircle(x: x, y: y, r: r);
});
_scheduleLiveRecalculate();
},
),
const SizedBox(height: 8),
Text(
_usedFallback
? 'Kreiserkennung per Fallback. Kreis direkt im Foto verschieben/zoomen.'
: 'Kreis erkannt. Direkt im Foto ziehen oder mit Pinch/Slider anpassen.',
style: Theme.of(context).textTheme.bodySmall,
),
if (_warning != null) ...[
const SizedBox(height: 6),
Text(
_warning!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.error,
fontWeight: FontWeight.w600,
),
),
],
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;
_hexCtrl.text = _colorToHexStatic(_selected);
setState(() {});
},
),
Slider(
value: _circleR,
min: 0.08,
max: 0.49,
label: 'Radius',
onChanged: (v) {
setState(() => _setAndClampCircle(r: v));
_scheduleLiveRecalculate();
},
),
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: ${_colorToHexStatic(_selected)}',
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
],
),
const SizedBox(height: 10),
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) return;
setState(() => _selected = parsed);
},
),
],
),
),
bottomNavigationBar: SafeArea(
minimum: const EdgeInsets.fromLTRB(12, 8, 12, 12),
child: Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () => Navigator.pop(context),
child: const Text('Abbrechen'),
),
),
const SizedBox(width: 8),
Expanded(
child: FilledButton(
onPressed: () {
final name = _nameCtrl.text.trim();
if (name.isEmpty) return;
final parsed = _parseHex(_hexCtrl.text);
Navigator.pop(
context,
_CapPhotoReviewResult(
name: name,
color: parsed ?? _selected,
mode: _mode,
previewBytes: _previewBytes,
),
);
},
child: const Text('Zum Katalog hinzufügen'),
),
),
],
),
),
);
}
}
String _colorToHexStatic(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 ? 84.0 : 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: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: radius,
border: Border.all(color: Colors.black12)),
child: const Icon(Icons.photo_outlined),
);
}
}
class _ColorSwatch extends StatelessWidget {
final Color color;
final bool large;
const _ColorSwatch({required this.color, this.large = false});
@override
Widget build(BuildContext context) {
final size = large ? 58.0 : 26.0;
return Container(
width: size,
height: size,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(large ? 12 : 999),
border: Border.all(color: Colors.black26),
),
);
}
}
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 _NormalizedCircle {
final double x;
final double y;
final double r;
const _NormalizedCircle({required this.x, required this.y, required this.r});
}
class _CircleAdjustOverlay extends StatefulWidget {
final Uint8List imageBytes;
final double imageWidth;
final double imageHeight;
final double circleX;
final double circleY;
final double circleR;
final void Function(double x, double y, double r) onCircleChanged;
const _CircleAdjustOverlay({
required this.imageBytes,
required this.imageWidth,
required this.imageHeight,
required this.circleX,
required this.circleY,
required this.circleR,
required this.onCircleChanged,
});
@override
State<_CircleAdjustOverlay> createState() => _CircleAdjustOverlayState();
}
class _CircleAdjustOverlayState extends State<_CircleAdjustOverlay> {
double? _baseRadius;
@override
Widget build(BuildContext context) {
return LayoutBuilder(builder: (context, constraints) {
final maxW = math.min(320.0, constraints.maxWidth);
final shownH = maxW * widget.imageHeight / widget.imageWidth;
return Center(
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: SizedBox(
width: maxW,
height: shownH,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onScaleStart: (_) => _baseRadius = widget.circleR,
onScaleUpdate: (details) {
final local = details.localFocalPoint;
final x = (local.dx / maxW).clamp(0.0, 1.0);
final y = (local.dy / shownH).clamp(0.0, 1.0);
final scale = details.scale;
final r =
((_baseRadius ?? widget.circleR) * scale).clamp(0.06, 0.49);
widget.onCircleChanged(x, y, r);
},
onScaleEnd: (_) => _baseRadius = null,
child: Stack(
fit: StackFit.expand,
children: [
Image.memory(widget.imageBytes, fit: BoxFit.cover),
CustomPaint(
painter: _CircleOverlayPainter(
x: widget.circleX,
y: widget.circleY,
r: widget.circleR,
),
),
],
),
),
),
),
);
});
}
}
class _CircleOverlayPainter extends CustomPainter {
final double x;
final double y;
final double r;
const _CircleOverlayPainter(
{required this.x, required this.y, required this.r});
@override
void paint(Canvas canvas, Size size) {
final center = Offset(x * size.width, y * size.height);
final radius = r * math.min(size.width, size.height);
final overlayPaint = Paint()..color = Colors.black.withValues(alpha: 0.35);
final overlayPath = Path()..addRect(Offset.zero & size);
overlayPath.addOval(Rect.fromCircle(center: center, radius: radius));
overlayPath.fillType = PathFillType.evenOdd;
canvas.drawPath(overlayPath, overlayPaint);
final ringPaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 2.5
..color = Colors.white;
canvas.drawCircle(center, radius, ringPaint);
final ringPaint2 = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1.2
..color = const Color(0xFF2196F3);
canvas.drawCircle(center, radius - 3, ringPaint2);
final cross = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1
..color = Colors.white70;
canvas.drawLine(Offset(center.dx - 10, center.dy),
Offset(center.dx + 10, center.dy), cross);
canvas.drawLine(Offset(center.dx, center.dy - 10),
Offset(center.dx, center.dy + 10), cross);
}
@override
bool shouldRepaint(covariant _CircleOverlayPainter oldDelegate) {
return oldDelegate.x != x || oldDelegate.y != y || oldDelegate.r != r;
}
}
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 minSide = math.min(decoded.width, decoded.height).toDouble();
final normalized = _clampCircleNormalized(
imageW: decoded.width.toDouble(),
imageH: decoded.height.toDouble(),
circleX: (request['circleX'] as num).toDouble(),
circleY: (request['circleY'] as num).toDouble(),
circleR: (request['circleR'] as num).toDouble(),
);
final circle = _DetectedCircle(
cx: normalized.x * decoded.width,
cy: normalized.y * decoded.height,
r: normalized.r * minSide,
);
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)),
};
}
_NormalizedCircle _clampCircleNormalized({
required double imageW,
required double imageH,
required double circleX,
required double circleY,
required double circleR,
}) {
final minSide = math.min(imageW, imageH);
final r = circleR.clamp(8 / minSide, 0.49);
final minX = r * (minSide / imageW);
final minY = r * (minSide / imageH);
return _NormalizedCircle(
x: circleX.clamp(minX, 1 - minX),
y: circleY.clamp(minY, 1 - minY),
r: r,
);
}
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);
}