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

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