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 createState() => _MosaicHomePageState(); } class _MosaicHomePageState extends State 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 _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 _catalogFile() async { final docs = await getApplicationDocumentsDirectory(); return File('${docs.path}/cap_catalog.json'); } Future _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 _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; _catalog = jsonRaw .map((e) => CapCatalogEntry.fromJson(e as Map)) .toList(); } if (_catalog.isEmpty) { _catalog = defaults; await _persistCatalog(); } } catch (_) { _catalog = defaults; } if (!mounted) return; setState(() => _isCatalogLoaded = true); } Future _persistCatalog() async { final file = await _catalogFile(); final jsonData = jsonEncode(_catalog.map((e) => e.toJson()).toList()); await file.writeAsString(jsonData, flush: true); } Future _pickImage() async { final XFile? picked = await _picker.pickImage(source: ImageSource.gallery); if (picked == null) return; final bytes = await picked.readAsBytes(); setState(() { _sourceImageBytes = bytes; _result = null; }); } Future _pendingCaptureFile() async { final docs = await getApplicationDocumentsDirectory(); return File('${docs.path}/pending_cap_capture.jpg'); } Future _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 _readPendingCapture() async { final file = await _pendingCaptureFile(); if (!await file.exists()) return null; return file.readAsBytes(); } Future _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 _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 _openCaptureReviewFlow( Uint8List bytes, { bool recoveredFromPending = false, }) async { Map 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 _addCapDialog() async { Color selected = Colors.orange; final nameCtrl = TextEditingController(); final hexCtrl = TextEditingController(text: _colorToHex(selected)); String? imagePath; await showDialog( 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 _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( 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 _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 _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 = { 'source': _sourceImageBytes!, 'useCapSize': _useCapSize, 'gridW': gridW, 'gridH': gridH, 'capSize': capSize, 'fidelityStructure': _fidelityStructure, 'ditheringStrength': _ditheringStrength, 'edgeEmphasis': _edgeEmphasis, 'colorVariation': _colorVariation, 'palette': _catalog .map((p) => {'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(); final counts = {}; for (int i = 0; i < palette.length; i++) { counts[palette[i]] = countsList[i]; } final sortedCounts = counts.entries.toList() ..sort((a, b) => b.value.compareTo(a.value)); setState(() { _result = MosaicResult( width: out['width'] as int, height: out['height'] as int, assignments: (out['assignments'] as List).cast(), palette: palette, counts: counts, sortedCounts: sortedCounts, previewPng: out['previewPng'] as Uint8List, ); _isGenerating = false; }); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: 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( segments: const [ ButtonSegment(value: false, label: Text('Grid W x H')), ButtonSegment(value: true, label: Text('Cap size (px)')), ], selected: {_useCapSize}, onSelectionChanged: (s) { setState(() => _useCapSize = s.first); _scheduleRegenerate(); }, ), const SizedBox(height: 8), if (!_useCapSize) Row( children: [ Expanded( child: TextField( controller: _gridWidthCtrl, keyboardType: TextInputType.number, decoration: const InputDecoration(labelText: 'Grid Width')), ), const SizedBox(width: 8), Expanded( child: TextField( controller: _gridHeightCtrl, keyboardType: TextInputType.number, decoration: const InputDecoration(labelText: 'Grid Height')), ), ], ) else TextField( controller: _capSizeCtrl, keyboardType: TextInputType.number, decoration: const InputDecoration( labelText: 'Approx cap size in source image (pixels)'), ), const SizedBox(height: 16), const Text('Style Preset', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), const SizedBox(height: 8), SegmentedButton( segments: const [ ButtonSegment( value: StylePreset.realistisch, label: Text('Realistisch')), ButtonSegment( value: StylePreset.ausgewogen, label: Text('Ausgewogen')), ButtonSegment( value: StylePreset.kuenstlerisch, label: Text('Künstlerisch')), ], selected: {_selectedPreset}, onSelectionChanged: (s) => _applyPreset(s.first), ), const SizedBox(height: 8), Card( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: Column( children: [ _SliderRow( label: 'Fidelity ↔ Structure', leftLabel: 'Fidelity', rightLabel: 'Structure', value: _fidelityStructure, onChanged: (v) { setState(() => _fidelityStructure = v); _onStyleChanged(); }), _SliderRow( label: 'Dithering strength', leftLabel: 'Off', rightLabel: 'Strong', value: _ditheringStrength, onChanged: (v) { setState(() => _ditheringStrength = v); _onStyleChanged(); }), _SliderRow( label: 'Edge emphasis', leftLabel: 'Soft', rightLabel: 'Crisp', value: _edgeEmphasis, onChanged: (v) { setState(() => _edgeEmphasis = v); _onStyleChanged(); }), _SliderRow( label: 'Color tolerance / variation', leftLabel: 'Strict', rightLabel: 'Varied', value: _colorVariation, onChanged: (v) { setState(() => _colorVariation = v); _onStyleChanged(); }), ], ), ), ), const SizedBox(height: 16), 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 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 _recalculate({bool updateUiBefore = true}) async { final localToken = ++_recalcToken; if (updateUiBefore && mounted) setState(() {}); try { final adjusted = await compute( _extractCapFromAdjustedCircleIsolate, { '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( 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 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 toJson() => { 'id': id, 'name': name, 'colorValue': colorValue, 'imagePath': imagePath, }; factory CapCatalogEntry.fromJson(Map 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 assignments; final List palette; final Map counts; final List> sortedCounts; final Uint8List previewPng; MosaicResult({ required this.width, required this.height, required this.assignments, required this.palette, required this.counts, required this.sortedCounts, required this.previewPng, }); } Map _generateMosaicIsolate(Map request) { final source = request['source'] as Uint8List; final useCapSize = request['useCapSize'] as bool; final defaultGridW = request['gridW'] as int; final defaultGridH = request['gridH'] as int; final capSize = request['capSize'] as int; final fidelityStructure = (request['fidelityStructure'] as num).toDouble(); final ditheringStrength = (request['ditheringStrength'] as num).toDouble(); final edgeEmphasis = (request['edgeEmphasis'] as num).toDouble(); final colorVariation = (request['colorVariation'] as num).toDouble(); final paletteRaw = (request['palette'] as List).cast(); final decoded = img.decodeImage(source); if (decoded == null) { return { 'width': 1, 'height': 1, 'assignments': [0], 'counts': [1], 'palette': paletteRaw, 'previewPng': Uint8List.fromList(img.encodePng(img.Image(width: 1, height: 1))), }; } final gridW = useCapSize ? math.max(1, (decoded.width / capSize).round()) : defaultGridW; final gridH = useCapSize ? math.max(1, (decoded.height / capSize).round()) : defaultGridH; final interpolation = fidelityStructure < 0.4 ? img.Interpolation.average : img.Interpolation.linear; final scaled = img.copyResize(decoded, width: gridW, height: gridH, interpolation: interpolation); final pixelCount = gridW * gridH; final workingR = List.filled(pixelCount, 0); final workingG = List.filled(pixelCount, 0); final workingB = List.filled(pixelCount, 0); final luminance = List.filled(pixelCount, 0); for (int y = 0; y < gridH; y++) { for (int x = 0; x < gridW; x++) { final idx = y * gridW + x; final pix = scaled.getPixel(x, y); final r = pix.r.toDouble(); final g = pix.g.toDouble(); final b = pix.b.toDouble(); workingR[idx] = r; workingG[idx] = g; workingB[idx] = b; luminance[idx] = 0.2126 * r + 0.7152 * g + 0.0722 * b; } } final edgeStrength = List.filled(pixelCount, 0); for (int y = 0; y < gridH; y++) { final ym = y > 0 ? y - 1 : y; final yp = y + 1 < gridH ? y + 1 : y; for (int x = 0; x < gridW; x++) { final xm = x > 0 ? x - 1 : x; final xp = x + 1 < gridW ? x + 1 : x; final left = luminance[y * gridW + xm]; final right = luminance[y * gridW + xp]; final up = luminance[ym * gridW + x]; final down = luminance[yp * gridW + x]; final gx = right - left; final gy = down - up; edgeStrength[y * gridW + x] = (math.sqrt(gx * gx + gy * gy) / 255.0).clamp(0.0, 1.0); } } if (fidelityStructure > 0.001) { final blurredR = List.filled(pixelCount, 0); final blurredG = List.filled(pixelCount, 0); final blurredB = List.filled(pixelCount, 0); for (int y = 0; y < gridH; y++) { for (int x = 0; x < gridW; x++) { double sr = 0, sg = 0, sb = 0; int c = 0; for (int ny = math.max(0, y - 1); ny <= math.min(gridH - 1, y + 1); ny++) { for (int nx = math.max(0, x - 1); nx <= math.min(gridW - 1, x + 1); nx++) { final ni = ny * gridW + nx; sr += workingR[ni]; sg += workingG[ni]; sb += workingB[ni]; c++; } } final idx = y * gridW + x; blurredR[idx] = sr / c; blurredG[idx] = sg / c; blurredB[idx] = sb / c; } } for (int i = 0; i < pixelCount; i++) { final smoothBlend = fidelityStructure * (1.0 - edgeStrength[i] * 0.75); workingR[i] = _mix(workingR[i], blurredR[i], smoothBlend); workingG[i] = _mix(workingG[i], blurredG[i], smoothBlend); workingB[i] = _mix(workingB[i], blurredB[i], smoothBlend); } } final paletteValues = paletteRaw.map((e) => e['value'] as int).toList(); final paletteLab = paletteValues.map(_argbToLab).toList(growable: false); final assignments = List.filled(pixelCount, 0); final counts = List.filled(paletteValues.length, 0); final errorR = List.filled(pixelCount, 0); final errorG = List.filled(pixelCount, 0); final errorB = List.filled(pixelCount, 0); final labCache = >{}; for (int y = 0; y < gridH; y++) { for (int x = 0; x < gridW; x++) { final idx = y * gridW + x; final srcR = (workingR[idx] + errorR[idx]).clamp(0.0, 255.0); final srcG = (workingG[idx] + errorG[idx]).clamp(0.0, 255.0); final srcB = (workingB[idx] + errorB[idx]).clamp(0.0, 255.0); final rgb = ((srcR.round() & 0xFF) << 16) | ((srcG.round() & 0xFF) << 8) | (srcB.round() & 0xFF); final srcLab = labCache.putIfAbsent(rgb, () => _rgbToLab(rgb)); int bestIdx = 0; double bestDistance = double.infinity; final candidates = <_Candidate>[]; final localVar = colorVariation * (1.0 - edgeStrength[idx]).clamp(0.0, 1.0); final tolerance = 2.0 + localVar * 22.0; for (int i = 0; i < paletteLab.length; i++) { final lDiff = (srcLab[0] - paletteLab[i][0]).abs(); final d = _deltaE76(srcLab, paletteLab[i]) + (edgeEmphasis * edgeStrength[idx] * lDiff * 0.35); if (d < bestDistance) { bestDistance = d; bestIdx = i; } } for (int i = 0; i < paletteLab.length; i++) { final lDiff = (srcLab[0] - paletteLab[i][0]).abs(); final d = _deltaE76(srcLab, paletteLab[i]) + (edgeEmphasis * edgeStrength[idx] * lDiff * 0.35); if (d <= bestDistance + tolerance) { candidates.add(_Candidate(index: i, distance: d)); } } int chosen = bestIdx; if (localVar > 0.001 && candidates.length > 1) { candidates.sort((a, b) => a.distance.compareTo(b.distance)); final usable = math.min(candidates.length, 4); final h = ((x + 1) * 73856093) ^ ((y + 1) * 19349663); final pick = h.abs() % usable; chosen = candidates[pick].index; } assignments[idx] = chosen; counts[chosen]++; if (ditheringStrength > 0.001) { final argb = paletteValues[chosen]; final qr = ((argb >> 16) & 0xFF).toDouble(); final qg = ((argb >> 8) & 0xFF).toDouble(); final qb = (argb & 0xFF).toDouble(); final er = (srcR - qr) * ditheringStrength; final eg = (srcG - qg) * ditheringStrength; final eb = (srcB - qb) * ditheringStrength; if (x + 1 < gridW) { final n = idx + 1; errorR[n] += er * (7 / 16); errorG[n] += eg * (7 / 16); errorB[n] += eb * (7 / 16); } if (y + 1 < gridH) { if (x > 0) { final n = idx + gridW - 1; errorR[n] += er * (3 / 16); errorG[n] += eg * (3 / 16); errorB[n] += eb * (3 / 16); } final nDown = idx + gridW; errorR[nDown] += er * (5 / 16); errorG[nDown] += eg * (5 / 16); errorB[nDown] += eb * (5 / 16); if (x + 1 < gridW) { final n = idx + gridW + 1; errorR[n] += er * (1 / 16); errorG[n] += eg * (1 / 16); errorB[n] += eb * (1 / 16); } } } } } final preview = img.Image(width: gridW, height: gridH); for (int y = 0; y < gridH; y++) { for (int x = 0; x < gridW; x++) { final idx = assignments[y * gridW + x]; final argb = paletteValues[idx]; preview.setPixelRgba( x, y, (argb >> 16) & 0xFF, (argb >> 8) & 0xFF, argb & 0xFF, 255); } } return { 'width': gridW, 'height': gridH, 'assignments': assignments, 'counts': counts, 'palette': paletteRaw, 'previewPng': Uint8List.fromList(img.encodePng(preview, level: 1)), }; } Map _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 _extractCapFromAdjustedCircleIsolate( Map 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.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.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 = {}; 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 _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 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 _argbToLab(int argb) { final rgb = argb & 0x00FFFFFF; return _rgbToLab(rgb); } List _rgbToLab(int rgb) { double r = ((rgb >> 16) & 0xFF) / 255.0; double g = ((rgb >> 8) & 0xFF) / 255.0; double b = (rgb & 0xFF) / 255.0; r = r <= 0.04045 ? r / 12.92 : math.pow((r + 0.055) / 1.055, 2.4).toDouble(); g = g <= 0.04045 ? g / 12.92 : math.pow((g + 0.055) / 1.055, 2.4).toDouble(); b = b <= 0.04045 ? b / 12.92 : math.pow((b + 0.055) / 1.055, 2.4).toDouble(); final x = (r * 0.4124 + g * 0.3576 + b * 0.1805) / 0.95047; final y = (r * 0.2126 + g * 0.7152 + b * 0.0722) / 1.00000; final z = (r * 0.0193 + g * 0.1192 + b * 0.9505) / 1.08883; double f(double t) => t > 0.008856 ? math.pow(t, 1.0 / 3.0).toDouble() : (7.787 * t) + (16 / 116); final fx = f(x); final fy = f(y); final fz = f(z); final l = (116 * fy) - 16; final a = 500 * (fx - fy); final bStar = 200 * (fy - fz); return [l, a, bStar]; } double _deltaE76(List lab1, List lab2) { final dl = lab1[0] - lab2[0]; final da = lab1[1] - lab2[1]; final db = lab1[2] - lab2[2]; return math.sqrt(dl * dl + da * da + db * db); }