diff --git a/README.md b/README.md index 04ad371..bcc5693 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,8 @@ Prototype Flutter app for generating bottle-cap mosaics from imported images. - survives app restarts - Catalog management: - add entries manually (name + hex/color picker + optional photo) - - **Deckel fotografieren**: capture a cap with camera, auto-detect dominant center color, store cropped thumbnail + - **Deckel fotografieren**: robust circular cap detection (edge-based circle search with fallback), color is computed only from masked cap interior pixels + - selectable extraction mode in photo review dialog: **Dominante Farbe** or **Durchschnitt** - dedicated catalog browser with **list/grid** modes - edit existing entry name/color - delete entries (with thumbnail cleanup) diff --git a/lib/main.dart b/lib/main.dart index 9ae29dd..2cf0202 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'dart:math' as math; -import 'dart:typed_data'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -32,6 +31,8 @@ enum StylePreset { realistisch, ausgewogen, kuenstlerisch } enum CatalogViewMode { list, grid } +enum ColorExtractionMode { dominant, average } + class MosaicHomePage extends StatefulWidget { const MosaicHomePage({super.key}); @@ -57,6 +58,7 @@ class _MosaicHomePageState extends State { int _generationToken = 0; bool _isCatalogLoaded = false; CatalogViewMode _catalogViewMode = CatalogViewMode.grid; + ColorExtractionMode _colorExtractionMode = ColorExtractionMode.dominant; double _fidelityStructure = 0.5; double _ditheringStrength = 0.35; @@ -154,11 +156,16 @@ class _MosaicHomePageState extends State { if (captured == null) return; final bytes = await captured.readAsBytes(); - final detected = await compute(_extractCapFromCenterIsolate, bytes); + final detected = await compute(_extractCapFromPhotoIsolate, bytes); if (!mounted) return; - Color selected = Color(detected['color'] as int); + final dominantColor = Color(detected['dominantColor'] as int); + final averageColor = Color(detected['averageColor'] as int); final previewBytes = detected['previewPng'] as Uint8List; + final bool usedFallback = detected['usedFallback'] as bool? ?? false; + ColorExtractionMode mode = _colorExtractionMode; + Color selected = + mode == ColorExtractionMode.dominant ? dominantColor : averageColor; _photoCapNameCtrl.text = 'Fotografierter Deckel'; _photoCapHexCtrl.text = _colorToHex(selected); @@ -181,7 +188,36 @@ class _MosaicHomePageState extends State { ), const SizedBox(height: 8), Text( - 'Farbe wird aus dem mittigen Kreisbereich erkannt.', + usedFallback + ? 'Kreis wurde per Kanten-Fallback erkannt.' + : 'Kreis wurde automatisch auf dem Deckel erkannt.', + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 8), + SegmentedButton( + segments: const [ + ButtonSegment( + value: ColorExtractionMode.dominant, + label: Text('Dominante Farbe'), + ), + ButtonSegment( + value: ColorExtractionMode.average, + label: Text('Durchschnitt'), + ), + ], + selected: {mode}, + onSelectionChanged: (selection) { + mode = selection.first; + selected = mode == ColorExtractionMode.dominant + ? dominantColor + : averageColor; + _photoCapHexCtrl.text = _colorToHex(selected); + setDialogState(() {}); + }, + ), + const SizedBox(height: 8), + Text( + 'Nur Pixel innerhalb des erkannten Kreisinneren werden ausgewertet.', style: Theme.of(context).textTheme.bodySmall, ), const SizedBox(height: 12), @@ -235,12 +271,14 @@ class _MosaicHomePageState extends State { final parsed = _parseHex(_photoCapHexCtrl.text); final entry = CapCatalogEntry.newEntry( name: name, color: parsed ?? selected); + _colorExtractionMode = mode; entry.imagePath = await _saveThumbnail(previewBytes, entry.id); _catalog.add(entry); await _persistCatalog(); if (!mounted) return; setState(() {}); + if (!ctx.mounted) return; Navigator.pop(ctx); _scheduleRegenerate(); }, @@ -321,8 +359,9 @@ class _MosaicHomePageState extends State { const InputDecoration(labelText: 'Hex (#RRGGBB)'), onChanged: (value) { final parsed = _parseHex(value); - if (parsed != null) + if (parsed != null) { setDialogState(() => selected = parsed); + } }, ), const SizedBox(height: 8), @@ -369,6 +408,7 @@ class _MosaicHomePageState extends State { await _persistCatalog(); if (!mounted) return; setState(() {}); + if (!ctx.mounted) return; Navigator.pop(ctx); _scheduleRegenerate(); }, @@ -433,6 +473,7 @@ class _MosaicHomePageState extends State { await _persistCatalog(); if (!mounted) return; setState(() {}); + if (!ctx.mounted) return; Navigator.pop(ctx); _scheduleRegenerate(); }, @@ -1195,43 +1236,161 @@ Map _generateMosaicIsolate(Map request) { }; } -Map _extractCapFromCenterIsolate(Uint8List sourceBytes) { +Map _extractCapFromPhotoIsolate(Uint8List sourceBytes) { final decoded = img.decodeImage(sourceBytes); if (decoded == null) { return { - 'color': Colors.orange.toARGB32(), + 'dominantColor': Colors.orange.toARGB32(), + 'averageColor': Colors.orange.toARGB32(), + 'usedFallback': true, 'previewPng': Uint8List.fromList( img.encodePng(img.Image(width: 1, height: 1), level: 1)), }; } - final cropSize = math.min(decoded.width, decoded.height); - final startX = (decoded.width - cropSize) ~/ 2; - final startY = (decoded.height - cropSize) ~/ 2; - final centered = img.copyCrop(decoded, - x: startX, y: startY, width: cropSize, height: cropSize); + 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 analysisSize = centered.width > 420 - ? img.copyResize(centered, width: 420, height: 420) - : centered; + final analysis = scale < 1.0 + ? img.copyResize(decoded, + width: (decoded.width * scale).round(), + height: (decoded.height * scale).round(), + interpolation: img.Interpolation.average) + : decoded; - final cx = analysisSize.width / 2; - final cy = analysisSize.height / 2; - final radius = math.min(analysisSize.width, analysisSize.height) * 0.30; - final r2 = radius * radius; + final 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 = 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 { + 'dominantColor': stats.dominantArgb, + 'averageColor': stats.averageArgb, + 'usedFallback': usedFallback, + 'previewPng': Uint8List.fromList(img.encodePng(preview, level: 1)), + }; +} + +_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 buckets = {}; double sumR = 0; double sumG = 0; double sumB = 0; int included = 0; - for (int y = 0; y < analysisSize.height; y++) { - final dy = y - cy; - for (int x = 0; x < analysisSize.width; x++) { - final dx = x - cx; + final insetRadius = circle.r * 0.78; + 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; if ((dx * dx) + (dy * dy) > r2) continue; - final p = analysisSize.getPixel(x, y); + + final p = image.getPixel(x, y); final r = p.r.toInt(); final g = p.g.toInt(); final b = p.b.toInt(); @@ -1247,33 +1406,66 @@ Map _extractCapFromCenterIsolate(Uint8List sourceBytes) { } } - int resultArgb; if (included == 0) { - resultArgb = const Color(0xFFFF9800).toARGB32(); - } else { - _RgbBucket? dominant; - for (final bucket in buckets.values) { - if (dominant == null || bucket.count > dominant.count) dominant = bucket; - } + final fallback = const Color(0xFFFF9800).toARGB32(); + return _CapColorStats(averageArgb: fallback, dominantArgb: fallback); + } - if (dominant == null || dominant.count < included * 0.08) { - resultArgb = Color.fromARGB(255, (sumR / included).round(), - (sumG / included).round(), (sumB / included).round()) - .toARGB32(); - } else { - resultArgb = Color.fromARGB( + final average = Color.fromARGB(255, (sumR / included).round(), + (sumG / included).round(), (sumB / included).round()) + .toARGB32(); + + _RgbBucket? dominant; + for (final bucket in buckets.values) { + if (dominant == null || bucket.count > dominant.count) dominant = bucket; + } + final dominantArgb = dominant == null + ? average + : Color.fromARGB( 255, (dominant.r / dominant.count).round(), (dominant.g / dominant.count).round(), (dominant.b / dominant.count).round()) .toARGB32(); - } - } - return { - 'color': resultArgb, - 'previewPng': Uint8List.fromList(img.encodePng(centered, level: 1)), - }; + return _CapColorStats(averageArgb: average, dominantArgb: dominantArgb); +} + +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 _RgbBucket {