Add camera cap capture flow with center color detection
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
47
android/app/src/main/AndroidManifest.xml
Normal file
47
android/app/src/main/AndroidManifest.xml
Normal 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
72
ios/Runner/Info.plist
Normal 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>
|
||||
238
lib/main.dart
238
lib/main.dart
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user