Add camera cap capture flow with center color detection

This commit is contained in:
gary
2026-02-21 20:37:18 +01:00
parent 4cbd4eb478
commit 2e0da448ba
4 changed files with 353 additions and 6 deletions

View File

@@ -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

View File

@@ -0,0 +1,47 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.CAMERA" />
<application
android:label="korken_mosaic"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>

72
ios/Runner/Info.plist Normal file
View File

@@ -0,0 +1,72 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Korken Mosaic</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>korken_mosaic</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>Die Kamera wird verwendet, um einen Flaschendeckel für die Farbpalette zu fotografieren.</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>

View File

@@ -36,6 +36,8 @@ class MosaicHomePage extends StatefulWidget {
class _MosaicHomePageState extends State<MosaicHomePage> {
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<MosaicHomePage> {
_gridWidthCtrl.dispose();
_gridHeightCtrl.dispose();
_capSizeCtrl.dispose();
_photoCapNameCtrl.dispose();
_photoCapHexCtrl.dispose();
super.dispose();
}
@@ -90,6 +94,117 @@ class _MosaicHomePageState extends State<MosaicHomePage> {
});
}
Future<void> _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<void>(
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<MosaicHomePage> {
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<void>(
context: context,
@@ -165,8 +277,7 @@ class _MosaicHomePageState extends State<MosaicHomePage> {
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<MosaicHomePage> {
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<MosaicHomePage> {
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<String, dynamic> _generateMosaicIsolate(Map<String, dynamic> request) {
};
}
Map<String, dynamic> _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 = <int, _RgbBucket>{};
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;