diff --git a/README.md b/README.md index 49c1705..7fec748 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,8 @@ Prototype Flutter app for generating bottle-cap mosaics from imported images. - Cap palette management: - list caps with name + color - add color via picker and/or manual hex + - **Deckel fotografieren**: capture a cap with camera and auto-detect its color from a robust center-circle sample (reduced background contamination) + - review detected color preview, edit name + hex, then save as a normal palette entry - remove caps - Mosaic preview + bill of materials counts per cap color diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..6f4fc21 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist new file mode 100644 index 0000000..c10495d --- /dev/null +++ b/ios/Runner/Info.plist @@ -0,0 +1,72 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Korken Mosaic + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + korken_mosaic + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + NSCameraUsageDescription + Die Kamera wird verwendet, um einen Flaschendeckel für die Farbpalette zu fotografieren. + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/lib/main.dart b/lib/main.dart index 9114bf7..1bd8c25 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -36,6 +36,8 @@ class MosaicHomePage extends StatefulWidget { class _MosaicHomePageState extends State { final ImagePicker _picker = ImagePicker(); + final TextEditingController _photoCapNameCtrl = TextEditingController(); + final TextEditingController _photoCapHexCtrl = TextEditingController(); final TextEditingController _gridWidthCtrl = TextEditingController(text: '40'); final TextEditingController _gridHeightCtrl = @@ -77,6 +79,8 @@ class _MosaicHomePageState extends State { _gridWidthCtrl.dispose(); _gridHeightCtrl.dispose(); _capSizeCtrl.dispose(); + _photoCapNameCtrl.dispose(); + _photoCapHexCtrl.dispose(); super.dispose(); } @@ -90,6 +94,117 @@ class _MosaicHomePageState extends State { }); } + Future _captureCapPhoto() async { + final XFile? captured = await _picker.pickImage( + source: ImageSource.camera, + imageQuality: 95, + maxWidth: 1800, + ); + if (captured == null) return; + + final bytes = await captured.readAsBytes(); + final detected = await compute(_extractCapFromCenterIsolate, bytes); + if (!mounted) return; + + Color selected = Color(detected['color'] as int); + final previewBytes = detected['previewPng'] as Uint8List; + _photoCapNameCtrl.text = 'Fotografierter Deckel'; + _photoCapHexCtrl.text = _colorToHex(selected); + + showDialog( + context: context, + builder: (ctx) { + return StatefulBuilder( + builder: (ctx, setDialogState) { + return AlertDialog( + title: const Text('Deckel fotografieren'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Image.memory( + previewBytes, + width: 220, + height: 220, + fit: BoxFit.cover, + ), + ), + const SizedBox(height: 8), + Text( + 'Farbe wird aus dem mittigen Kreisbereich erkannt.', + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 12), + Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: selected, + border: Border.all(color: Colors.black26), + borderRadius: BorderRadius.circular(8), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + 'Erkannte Farbe: ${_colorToHex(selected)}', + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + const SizedBox(height: 10), + TextField( + controller: _photoCapNameCtrl, + decoration: const InputDecoration(labelText: 'Name'), + ), + const SizedBox(height: 8), + TextField( + controller: _photoCapHexCtrl, + decoration: + const InputDecoration(labelText: 'Hex (#RRGGBB)'), + onChanged: (value) { + final parsed = _parseHex(value); + if (parsed == null) return; + setDialogState(() => selected = parsed); + }, + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Abbrechen'), + ), + FilledButton( + onPressed: () { + final name = _photoCapNameCtrl.text.trim(); + if (name.isEmpty) return; + final parsed = _parseHex(_photoCapHexCtrl.text); + setState(() { + _palette.add( + CapColor(name: name, color: parsed ?? selected), + ); + }); + Navigator.pop(ctx); + _scheduleRegenerate(); + }, + child: const Text('Zur Palette hinzufügen'), + ), + ], + ); + }, + ); + }, + ); + } + void _scheduleRegenerate() { if (_sourceImageBytes == null || _result == null) return; _debounceTimer?.cancel(); @@ -133,10 +248,7 @@ class _MosaicHomePageState extends State { void _addCapDialog() { Color selected = Colors.orange; final nameCtrl = TextEditingController(); - final hexCtrl = TextEditingController( - text: - '#${selected.toARGB32().toRadixString(16).substring(2).toUpperCase()}', - ); + final hexCtrl = TextEditingController(text: _colorToHex(selected)); showDialog( context: context, @@ -165,8 +277,7 @@ class _MosaicHomePageState extends State { pickerColor: selected, onColorChanged: (c) { selected = c; - hexCtrl.text = - '#${c.toARGB32().toRadixString(16).substring(2).toUpperCase()}'; + hexCtrl.text = _colorToHex(c); }, ), ], @@ -416,9 +527,16 @@ class _MosaicHomePageState extends State { style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), ), ), + OutlinedButton.icon( + onPressed: _captureCapPhoto, + icon: const Icon(Icons.photo_camera_outlined), + label: const Text('Deckel fotografieren'), + ), + const SizedBox(width: 8), IconButton( onPressed: _addCapDialog, icon: const Icon(Icons.add_circle_outline), + tooltip: 'Farbe manuell hinzufügen', ), ], ), @@ -485,6 +603,10 @@ class _MosaicHomePageState extends State { if (value == null) return null; return Color(0xFF000000 | value); } + + String _colorToHex(Color color) { + return '#${color.toARGB32().toRadixString(16).substring(2).toUpperCase()}'; + } } class _SliderRow extends StatelessWidget { @@ -795,6 +917,110 @@ Map _generateMosaicIsolate(Map request) { }; } +Map _extractCapFromCenterIsolate(Uint8List sourceBytes) { + final decoded = img.decodeImage(sourceBytes); + if (decoded == null) { + return { + 'color': Colors.orange.toARGB32(), + 'previewPng': Uint8List.fromList( + img.encodePng(img.Image(width: 1, height: 1), level: 1), + ), + }; + } + + final cropSize = math.min(decoded.width, decoded.height); + final startX = (decoded.width - cropSize) ~/ 2; + final startY = (decoded.height - cropSize) ~/ 2; + final centered = img.copyCrop( + decoded, + x: startX, + y: startY, + width: cropSize, + height: cropSize, + ); + + final analysisSize = centered.width > 420 + ? img.copyResize(centered, width: 420, height: 420) + : centered; + + final cx = analysisSize.width / 2; + final cy = analysisSize.height / 2; + final radius = math.min(analysisSize.width, analysisSize.height) * 0.30; + final r2 = radius * radius; + + final buckets = {}; + double sumR = 0; + double sumG = 0; + double sumB = 0; + int included = 0; + + for (int y = 0; y < analysisSize.height; y++) { + final dy = y - cy; + for (int x = 0; x < analysisSize.width; x++) { + final dx = x - cx; + if ((dx * dx) + (dy * dy) > r2) continue; + final p = analysisSize.getPixel(x, y); + final r = p.r.toInt(); + final g = p.g.toInt(); + final b = p.b.toInt(); + + sumR += r; + sumG += g; + sumB += b; + included++; + + final bucketKey = ((r ~/ 16) << 8) | ((g ~/ 16) << 4) | (b ~/ 16); + final bucket = buckets.putIfAbsent(bucketKey, () => _RgbBucket()); + bucket.add(r, g, b); + } + } + + int resultArgb; + if (included == 0) { + resultArgb = const Color(0xFFFF9800).toARGB32(); + } else { + _RgbBucket? dominant; + for (final bucket in buckets.values) { + if (dominant == null || bucket.count > dominant.count) dominant = bucket; + } + + if (dominant == null || dominant.count < included * 0.08) { + resultArgb = Color.fromARGB( + 255, + (sumR / included).round(), + (sumG / included).round(), + (sumB / included).round(), + ).toARGB32(); + } else { + resultArgb = Color.fromARGB( + 255, + (dominant.r / dominant.count).round(), + (dominant.g / dominant.count).round(), + (dominant.b / dominant.count).round(), + ).toARGB32(); + } + } + + return { + 'color': resultArgb, + 'previewPng': Uint8List.fromList(img.encodePng(centered, level: 1)), + }; +} + +class _RgbBucket { + int count = 0; + int r = 0; + int g = 0; + int b = 0; + + void add(int nr, int ng, int nb) { + count++; + r += nr; + g += ng; + b += nb; + } +} + class _Candidate { final int index; final double distance;