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;